TentoCMS
Sdk

Nuxt

Using @tentocms/client with Nuxt 3 and Nuxt 4.

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.tentoApiKey and tentoPreviewKey are 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. Only tentoBaseUrl is 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.

Resources

Copyright © 2026