Nuxt
Complete guide for integrating TentoCMS with Nuxt 3 and Nuxt 4 applications.
Verified against Nuxt 3–4.
Installation
pnpm add @tentocms/client
Setup
Security: use a server-side client for SSR.
tentoApiKeyandtentoPreviewKeyare private runtimeConfig keys — they are empty string on the client. A universal Nuxt plugin (the default plugin location) runs on the client too and will crash with"API key is required". The recommended pattern below keeps the SDK and your API keys entirely server-side. OnlytentoBaseUrlis a public config value; it is safe to expose in a plugin.
1. Configure runtime config
Add to nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
// Private keys — server-side only, never sent to the browser
tentoApiKey: process.env.TENTO_API_KEY,
tentoPreviewKey: process.env.TENTO_PREVIEW_KEY,
// Public config — available on both server and client
public: {
tentoBaseUrl: process.env.TENTO_BASE_URL || 'https://tento-api.intelligentlending.co.uk',
},
},
})
2. Create a server-side utility
Create server/utils/tento.ts. This constructs a memoised TentoClient that is only ever instantiated inside the Node/Workers server process.
import { TentoClient } from '@tentocms/client'
let _client: TentoClient | undefined
let _previewClient: TentoClient | undefined
/**
* Returns a memoised TentoClient for use inside server routes and event handlers.
* Never import this in pages, components, or universal composables.
*/
export function getTentoClient(): TentoClient {
if (!_client) {
const config = useRuntimeConfig()
_client = new TentoClient({
apiKey: config.tentoApiKey,
baseUrl: config.public.tentoBaseUrl,
})
}
return _client
}
/**
* Returns a TentoClient with the preview key attached.
* Used inside the preview server route only.
*/
export function getTentoPreviewClient(): TentoClient {
if (!_previewClient) {
const config = useRuntimeConfig()
_previewClient = new TentoClient({
apiKey: config.tentoApiKey,
previewKey: config.tentoPreviewKey,
baseUrl: config.public.tentoBaseUrl,
})
}
return _previewClient
}
3. Create server API routes
Pages and components never call the SDK directly. They call a thin Nuxt server route that uses the server utility above. Only the rendered data reaches the browser.
server/api/cms/page.get.ts — fetch a page by slug:
export default defineEventHandler(async (event) => {
const { slug } = getQuery(event) as { slug: string }
if (!slug) {
throw createError({ statusCode: 400, message: 'slug is required' })
}
const tento = getTentoClient()
return tento.pages.getBySlug(slug)
})
server/api/cms/collection.get.ts — list collection items:
export default defineEventHandler(async (event) => {
const { type, limit, sort } = getQuery(event) as {
type: string
limit?: string
sort?: string
}
if (!type) {
throw createError({ statusCode: 400, message: 'type is required' })
}
const tento = getTentoClient()
return tento.collections.list(type, {
limit: limit ? Number(limit) : undefined,
sort,
})
})
4. Environment variables
Create .env:
TENTO_API_KEY=tento_pk_1234567890
TENTO_PREVIEW_KEY=preview_abc123def456789...
TENTO_BASE_URL=https://tento-api.intelligentlending.co.uk
Basic Usage
Pages fetch data through the server routes created above using useFetch or useAsyncData. The SDK and API keys never leave the server.
Pages
<script setup lang="ts">
const { data: page } = await useFetch('/api/cms/page', {
query: { slug: 'homepage' },
})
</script>
<template>
<div>
<h1>{{ page?.fields.title }}</h1>
<div v-html="page?.fields.content" />
</div>
</template>
Dynamic pages
<script setup lang="ts">
const route = useRoute()
const { data: page } = await useFetch('/api/cms/page', {
query: { slug: route.params.slug },
key: `page-${route.params.slug}`,
})
</script>
<template>
<div v-if="page">
<h1>{{ page.fields.title }}</h1>
<div v-html="page.fields.content" />
</div>
<div v-else>
<p>Page not found</p>
</div>
</template>
Collections
<script setup lang="ts">
const { data: products } = await useFetch('/api/cms/collection', {
query: { type: 'products', limit: 12, sort: '-createdAt' },
})
</script>
<template>
<div class="grid grid-cols-3 gap-4">
<div v-for="product in products?.data" :key="product.slug">
<h3>{{ product.name }}</h3>
<p>{{ product.price }}</p>
</div>
</div>
</template>
Preview Mode
Setup preview routes
Create server/api/preview.ts — validates the secret server-side and sets an httpOnly cookie:
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const slug = query.slug as string
const secret = query.secret as string
if (secret !== useRuntimeConfig().tentoPreviewKey) {
throw createError({
statusCode: 401,
message: 'Invalid preview secret'
})
}
setCookie(event, 'tento-preview', 'true', {
path: '/',
maxAge: 60 * 60, // 1 hour
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
})
return sendRedirect(event, `/${slug}`)
})
Create server/api/exit-preview.ts:
export default defineEventHandler(async (event) => {
deleteCookie(event, 'tento-preview')
return sendRedirect(event, '/')
})
Create server/api/cms/page-preview.get.ts — serves draft content when preview mode is active. Uses getTentoPreviewClient() from server/utils/tento.ts so the preview key stays server-side:
export default defineEventHandler(async (event) => {
const { slug } = getQuery(event) as { slug: string }
if (!slug) {
throw createError({ statusCode: 400, message: 'slug is required' })
}
const preview = getCookie(event, 'tento-preview') === 'true'
const tento = preview ? getTentoPreviewClient() : getTentoClient()
return tento.pages.getBySlug(slug)
})
Use in pages
<script setup lang="ts">
const route = useRoute()
const previewCookie = useCookie('tento-preview')
const isPreview = computed(() => previewCookie.value === 'true')
// Always hits the server route — the server decides which client to use
const { data: page } = await useFetch('/api/cms/page-preview', {
query: { slug: route.params.slug },
key: `page-preview-${route.params.slug}`,
})
</script>
<template>
<div>
<!-- Preview banner -->
<div v-if="isPreview" class="bg-yellow-400 p-4 text-center">
Preview Mode — viewing draft content.
<a href="/api/exit-preview" class="ml-4 underline">Exit preview</a>
</div>
<!-- Page content -->
<div v-if="page">
<h1>{{ page.fields.title }}</h1>
<div v-html="page.fields.content" />
</div>
</div>
</template>
Composables
Composables wrap useFetch / useAsyncData calls to the server routes. They do NOT import or instantiate TentoClient — that stays in server/utils/tento.ts.
Create composables/useTento.ts:
import type { CollectionListResponse } from '@tentocms/client'
/**
* Fetch a page by slug via the CMS server route.
*/
export function useTentoPage<T = Record<string, unknown>>(slug: string) {
return useFetch<T>('/api/cms/page', {
query: { slug },
key: `page-${slug}`,
})
}
/**
* Fetch collection items via the CMS server route.
*/
export function useTentoCollection<T = Record<string, unknown>>(
type: string,
options?: { limit?: number; sort?: string }
) {
// The route returns collections.list()'s envelope:
// { data: T[] | T, collectionType, pagination? } — `data` is a single item for singletons.
return useFetch<CollectionListResponse<T>>('/api/cms/collection', {
query: { type, ...options },
key: `collection-${type}`,
})
}
/**
* Fetch a page with preview support via the CMS server route.
* The server route reads the preview cookie and picks the right client.
*/
export function useTentoPagePreview<T = Record<string, unknown>>(slug: string) {
return useFetch<T>('/api/cms/page-preview', {
query: { slug },
key: `page-preview-${slug}`,
})
}
Using composables
<script setup lang="ts">
interface BlogPost {
title: string
excerpt: string
publishedAt: string
}
// Simple page fetch
const { data: homepage } = await useTentoPage('homepage')
// Collection fetch
const { data: products } = await useTentoCollection('products', {
limit: 10,
sort: '-createdAt',
})
// Page with preview support (cookie handled server-side)
const route = useRoute()
const { data: page } = await useTentoPagePreview(route.params.slug as string)
</script>
<template>
<div>
<h1>{{ homepage?.fields.title }}</h1>
<div class="products">
<div v-for="product in products?.data" :key="product.slug">
{{ product.name }}
</div>
</div>
<div v-if="page">
<h1>{{ page.fields.title }}</h1>
</div>
</div>
</template>
TypeScript
Type-safe content
<script setup lang="ts">
interface HomepageContent {
heroTitle: string
heroSubtitle: string
featuredProducts: string[]
}
const { data: homepage } = await useTentoPage<HomepageContent>('homepage')
</script>
<template>
<div>
<h1>{{ homepage?.fields.heroTitle }}</h1>
<p>{{ homepage?.fields.heroSubtitle }}</p>
</div>
</template>
Import types
import type {
Page,
CollectionItem,
BlogPost,
MediaItem,
} from '@tentocms/client'
Error handling
Handle errors in the server route and in the page component:
Server route (server/api/cms/page.get.ts):
import { TentoNotFoundError } from '@tentocms/client'
export default defineEventHandler(async (event) => {
const { slug } = getQuery(event) as { slug: string }
try {
const tento = getTentoClient()
return await tento.pages.getBySlug(slug)
} catch (err) {
if (err instanceof TentoNotFoundError) {
throw createError({ statusCode: 404, message: 'Page not found' })
}
throw err
}
})
Page component:
<script setup lang="ts">
const route = useRoute()
const { data: page, error } = await useFetch('/api/cms/page', {
query: { slug: route.params.slug },
key: `page-${route.params.slug}`,
})
if (error.value) {
throw createError({
statusCode: error.value.statusCode || 500,
message: error.value.message,
})
}
</script>
<template>
<div v-if="page">
<h1>{{ page.fields.title }}</h1>
</div>
</template>
SSG (Static Site Generation)
Generate static routes at build time. The nuxt.config.ts prerender hook runs in a Node process and can use environment variables directly:
// nuxt.config.ts
import { TentoClient } from '@tentocms/client'
export default defineNuxtConfig({
nitro: {
prerender: {
routes: async () => {
const tento = new TentoClient({
apiKey: process.env.TENTO_API_KEY!,
baseUrl: process.env.TENTO_BASE_URL!,
})
const pages = await tento.pages.list({ limit: 100 })
return pages.data.map(page => `/${page.slug}`)
},
},
},
})
This is a build-time script, not a browser or universal context, so constructing TentoClient directly is safe here.
Caching
Nuxt automatically caches useFetch / useAsyncData responses for the duration of a request. Configure a time-based cache with getCachedData:
<script setup lang="ts">
const nuxtApp = useNuxtApp()
const { data: page } = await useFetch('/api/cms/page', {
query: { slug: 'homepage' },
getCachedData: (key) => {
const cached = nuxtApp.payload.data[key] || nuxtApp.static.data[key]
if (!cached) return
// Invalidate after 1 hour
const timestamp = nuxtApp.payload.timestamps?.[key] || 0
if (Date.now() - timestamp > 3_600_000) return
return cached
},
})
</script>
Best practices
1. Keep the SDK server-side
All TentoClient usage lives in server/utils/tento.ts and server/api/cms/ routes. Pages and components only call useFetch('/api/cms/...'). This ensures:
- API keys never reach the browser.
- No client-side
"API key is required"crash. - A single place to update auth or retry logic.
2. Implement error boundaries
<script setup lang="ts">
const { data: page, error } = await useFetch('/api/cms/page', {
query: { slug: 'homepage' },
})
if (error.value) {
throw createError({
statusCode: error.value.statusCode || 500,
message: error.value.message,
})
}
</script>
3. Use composables for reusability
Centralise server-route calls in composables/useTento.ts rather than repeating useFetch paths across components.
4. Optimise images
<template>
<NuxtImg
:src="imageUrl"
:width="800"
:height="600"
alt="Product image"
/>
</template>
Resolve image URLs in the server route and return them as part of the page data, rather than calling SDK media helpers client-side.

