TentoCMS
Typegen

Nuxt

Generating typed content from your schemas in a Nuxt 4 project.

This guide shows you how to integrate TentoCMS type generation into your Nuxt 4 project for fully typed content fetching.

Installation

npm install -D @tentocms/typegen

Optional: the Page<T> wrapper type. The examples below that fetch a single page (e.g. Page<BlogPostFields>) import the Page type from @tentocms/client — it describes the { id, name, slug, type, fields, seo, publishedAt, updatedAt } envelope the REST API actually returns. typegen itself only generates the schema-shaped interfaces (*Content/*Fields/collection types); it does not emit a page-envelope wrapper. If you'd rather avoid the extra type-only dependency, replace Page<BlogPostFields> with an inline type or your own envelope interface.

Configuration

1. Create Configuration File

Run the init command to create a tentocms.config.js file:

npx tentocms-typegen init

init detects your project layout and writes a sensible default output path. It only checks for the absence of a ./src/ directory — there's no nuxt.config/app/-dir inspection. Because Nuxt 4 projects have no src/ directory, they fall into the no-src/ branch and get ./types/cms.ts:

// tentocms.config.js
export default {
  apiKey: process.env.TENTO_API_KEY,
  apiUrl: process.env.TENTO_BASE_URL,
  output: './types/cms.ts',
  watch: {
    interval: 30,
  },
}

If a ./src/ directory exists, the classic ./src/types/cms.ts default is used instead. The examples in this guide import from ~/types/cms, which Nuxt 4 resolves to app/types/cms.ts, so set output: './app/types/cms.ts' to match that import path.

2. Add Environment Variables

Create or update your .env file:

# .env
TENTO_API_KEY=your_api_key_here
TENTO_BASE_URL=https://tento-api.intelligentlending.co.uk

Add to .gitignore:

# .gitignore
.env
.env.*
!.env.example

generate automatically loads ./.env from the current directory before reading TENTO_API_KEY / TENTO_BASE_URL, so you usually don't need any special invocation:

# Auto-loads ./.env if present
tentocms-typegen generate

# Or point at an explicit file
tentocms-typegen generate -e ./config/.env.cms
  • -e, --env <path> loads the given file (and errors if it's missing).
  • Real environment variables are never overridden — a value already in process.env always wins, so the existing node --env-file=.env ... workflow keeps working.

Node ≥ 22/24: a bare --env-file is reserved by Node itself and won't reach this CLI — use -e/--env.

generate also accepts -o/--output, -k/--api-key, -u/--api-url, and -i/--interval (watch mode only) — each overrides the matching tentocms.config.js value. See the full CLI flag reference for the complete list, including a caveat about -c/--config currently being a no-op.

3. Add npm Scripts

Update your package.json:

{
  "scripts": {
    "dev": "nuxt dev",
    "build": "nuxt build",
    "types:generate": "tentocms-typegen generate",
    "types:watch": "tentocms-typegen generate --watch",
    "postinstall": "npm run types:generate"
  }
}

4. Generate Types

npm run types:generate

This writes the file at your configured output path (./app/types/cms.ts if you set it as recommended above) with your page types, collections, and component types.

Nuxt Runtime Config

Expose the base URL as a public runtime config value. The API key must remain private (server-only):

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only — never sent to the browser
    tentoApiKey: process.env.TENTO_API_KEY,
    // Public — safe to expose (base URL only, never the key)
    public: {
      tentoBaseUrl: process.env.TENTO_BASE_URL,
    },
  },
})

Usage in Nuxt

Server API Route (Required for Secure Fetching)

Never send the API key from the browser. Create a server route that proxies requests using the private key:

// server/api/cms/pages/[slug].get.ts
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '~/types/cms'

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const slug = getRouterParam(event, 'slug')
  if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' })

  // The REST API wraps the page in an envelope: { data, redirect }. Unwrap `data`
  // so the page component receives the page directly. The page is shaped like
  // `Page<BlogPostFields>` — `name`/`fields`/`seo` live at the top level, and
  // `fields` holds the generated `BlogPostFields` (the custom content only).
  const { data } = await $fetch<{ data: Page<BlogPostFields> }>(
    `${config.public.tentoBaseUrl}/api/v1/pages/${slug}`,
    {
      headers: {
        'X-API-Key': config.tentoApiKey,
      },
    }
  )

  return data
})

Fetching Pages with Type Safety

Your page components call the server route — the API key never reaches the browser:

<script setup lang="ts">
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '~/types/cms'

definePageMeta({ layout: 'default' })

const route = useRoute()

// Calls your server/api route, not the CMS directly
const { data: page } = await useFetch<Page<BlogPostFields>>(
  `/api/cms/pages/${route.params.slug}`
)
</script>

<template>
  <article v-if="page">
    <h1>{{ page.name }}</h1>

    <!-- Custom fields live under page.fields -->
    <div class="meta">
      <time>{{ page.fields.publishedDate }}</time>
      <span>{{ page.fields.author.name }}</span>
    </div>

    <div v-html="page.fields.body" />

    <!-- Components with full type safety -->
    <!-- Components use _type (kebab-case) as the discriminant -->
    <component
      v-for="(comp, index) in page.fields.components"
      :key="index"
      :is="getComponent(comp._type)"
      v-bind="comp"
    />
  </article>
</template>

Fetching Collections

// server/api/cms/collections/[slug].get.ts
import type { TeamMemberItem } from '~/types/cms'

export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig()
  const slug = getRouterParam(event, 'slug')
  if (!slug) throw createError({ statusCode: 400, statusMessage: 'Missing slug' })

  // Collections wrap items in an envelope: { data, collectionType, pagination? }.
  // `data` is an array for normal collections, or a single item for singletons —
  // normalise to an array so the component can always iterate.
  const { data } = await $fetch<{ data: TeamMemberItem[] | TeamMemberItem }>(
    `${config.public.tentoBaseUrl}/api/v1/collections/${slug}`,
    {
      headers: {
        'X-API-Key': config.tentoApiKey,
      },
    }
  )

  return Array.isArray(data) ? data : [data]
})
<script setup lang="ts">
import type { TeamMemberItem } from '~/types/cms'

// Typed collection response via server route
const { data: team } = await useFetch<TeamMemberItem[]>(
  '/api/cms/collections/team-members'
)
</script>

<template>
  <div class="team-grid">
    <div v-for="member in team" :key="member.id" class="team-card">
      <img :src="member.photo" :alt="member.name" />
      <h3>{{ member.name }}</h3>
      <p>{{ member.role }}</p>
      <!-- TypeScript autocompletes all your collection fields -->
    </div>
  </div>
</template>

Creating a Composable

Create a typed composable that calls your server routes:

// composables/useTentoCMS.ts
import type { Page } from '@tentocms/client'
import type { BlogPostFields, TeamMemberItem } from '~/types/cms'

export const useTentoCMS = () => {
  const fetchPage = async <T = Page<BlogPostFields>>(slug: string) => {
    return await $fetch<T>(`/api/cms/pages/${slug}`)
  }

  const fetchCollection = async <T = TeamMemberItem[]>(collectionSlug: string) => {
    return await $fetch<T>(`/api/cms/collections/${collectionSlug}`)
  }

  return {
    fetchPage,
    fetchCollection,
  }
}

Usage:

<script setup lang="ts">
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '~/types/cms'

const { fetchPage } = useTentoCMS()
const route = useRoute()

const page = await fetchPage<Page<BlogPostFields>>(route.params.slug as string)
</script>

Dynamic Component Rendering

<script setup lang="ts">
import type { Component } from 'vue'
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '~/types/cms'

const { fetchPage } = useTentoCMS()
const route = useRoute()

const page = await fetchPage<Page<BlogPostFields>>(route.params.slug as string)

// Component mapping — keys are the kebab-case _type values from the API.
// The map values are Vue components (from resolveComponent), so the value type
// is Vue's `Component`, not a generated CMS type.
const componentMap: Record<string, Component> = {
  'hero': resolveComponent('Hero'),
  'text-block': resolveComponent('TextBlock'),
  'image-gallery': resolveComponent('ImageGallery'),
  'cta-banner': resolveComponent('CtaBanner'),
}

// Match on comp._type, not comp.type
const getComponent = (_type: string) => {
  return componentMap[_type] || resolveComponent('div')
}
</script>

<template>
  <div>
    <!-- comp._type is the kebab-case discriminant; component fields are at the root -->
    <component
      v-for="(comp, index) in page.fields.components"
      :key="index"
      :is="getComponent(comp._type)"
      v-bind="comp"
    />
  </div>
</template>

Typing page.fields*Fields vs *Content

For every page type the generator emits two interfaces:

  • *Content (e.g. BlogPostContent) — base fields (id, slug, title, seo?) plus the custom fields.
  • *Fields (e.g. BlogPostFields) — only the custom fields.

The runtime page.fields object contains only the custom fields (the system id/slug/title live at the top level of the page, seo lives under page.seo). Use *Fields to type page.fields directly, without stripping the base fields:

<script setup lang="ts">
import type { BlogPostFields } from '~/types/cms'

const { data: page } = await useFetch('/api/cms/pages/hello-world')

// page.fields matches the custom-field shape exactly
const fields = page.value?.fields as BlogPostFields
</script>

Exception: if a page type declares its own content field named title, slug, id, or seo, that field appears in page.fields at runtime — and *Fields includes it. *Fields excludes the system base fields, not any same-named content field the page type itself declares.

Both interfaces are exported; *Content is unchanged from earlier versions.

Known Limitations

  • json fields are typed unknown, not Record<string, unknown>, because a json field may hold an array or an object. Narrow or cast at the use site before accessing properties — for example: const facts = fields.stats as Array<{ metric: string }>.
  • Resolved-reference and repeater field types can differ across components when the source schema models the same field differently. A background field defined as a reference in one component resolves to ResolvedReference, whereas the same field modelled as a plain object elsewhere is emitted as Record<string, unknown>. Similarly, an untyped repeater (a repeater field with no sub-fields defined in the schema) falls back to Record<string, unknown>[]. The generator faithfully reflects the source schema — for consistent, fully-typed output, align the field definitions in your TentoCMS schema (use the same field type and define repeater sub-fields wherever the field appears).

Development Workflow

Watch Mode for Development

Run the type generator in watch mode during development:

# Terminal 1
npm run types:watch

# Terminal 2
npm run dev

The type generator will automatically regenerate types when your CMS schemas change (polls every 30 seconds by default).

  1. Update your page types or collections in TentoCMS admin
  2. Types regenerate automatically (in watch mode)
  3. TypeScript shows errors in your IDE immediately
  4. Update your components to match the new schema

VSCode Settings

Add to .vscode/settings.json for better DX:

{
  "typescript.tsdk": "node_modules/typescript/lib",
  "typescript.enablePromptUseWorkspaceTsdk": true,
  "editor.codeActionsOnSave": {
    "source.fixAll": true
  }
}

Configuration Options

Full configuration reference for tentocms.config.js:

export default {
  // Your TentoCMS API key (use environment variable)
  apiKey: process.env.TENTO_API_KEY,

  // TentoCMS API URL
  apiUrl: process.env.TENTO_BASE_URL,

  // Output path for generated types. `init` picks this automatically based on
  // your layout (Nuxt projects default to ./types/cms.ts since there's no src/);
  // change it freely, e.g. ./app/types/cms.ts.
  output: './types/cms.ts',

  // Watch mode configuration
  watch: {
    // Polling interval in seconds
    interval: 30,
  },
}

Troubleshooting

Types Not Updating

  1. Check that TENTO_API_KEY is set in your .env file
  2. Verify the API key has read permissions
  3. Run npm run types:generate manually to see error messages
  4. Check the generated file timestamp

TypeScript Errors After Schema Changes

  1. Regenerate types: npm run types:generate
  2. Restart your TypeScript server in VSCode: Cmd+Shift+P → "TypeScript: Restart TS Server"
  3. If errors persist, check that your components match the new schema

API Key Not Found

Create a .env file in your project root:

TENTO_API_KEY=your_api_key_here
TENTO_BASE_URL=https://tento-api.intelligentlending.co.uk

Make sure Nuxt is configured to load it:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    tentoApiKey: process.env.TENTO_API_KEY,
    public: {
      tentoBaseUrl: process.env.TENTO_BASE_URL,
    },
  },
})

Next Steps

Copyright © 2026