Nuxt
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 thePagetype from@tentocms/client— it describes the{ id, name, slug, type, fields, seo, publishedAt, updatedAt }envelope the REST API actually returns.typegenitself 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, replacePage<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.tsdefault is used instead. The examples in this guide import from~/types/cms, which Nuxt 4 resolves toapp/types/cms.ts, so setoutput: './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.envalways wins, so the existingnode --env-file=.env ...workflow keeps working.
Node ≥ 22/24: a bare
--env-fileis 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
jsonfields are typedunknown, notRecord<string, unknown>, because ajsonfield 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
backgroundfield defined as a reference in one component resolves toResolvedReference, whereas the same field modelled as a plain object elsewhere is emitted asRecord<string, unknown>. Similarly, an untyped repeater (arepeaterfield with no sub-fields defined in the schema) falls back toRecord<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).
Recommended Workflow
- Update your page types or collections in TentoCMS admin
- Types regenerate automatically (in watch mode)
- TypeScript shows errors in your IDE immediately
- 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
- Check that
TENTO_API_KEYis set in your.envfile - Verify the API key has read permissions
- Run
npm run types:generatemanually to see error messages - Check the generated file timestamp
TypeScript Errors After Schema Changes
- Regenerate types:
npm run types:generate - Restart your TypeScript server in VSCode:
Cmd+Shift+P→ "TypeScript: Restart TS Server" - 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
- Read the Nuxt Integration Guide for preview mode and server-route patterns
- Browse the SDK Reference for the full client API surface

