Next.js
This guide shows you how to integrate TentoCMS type generation into your Next.js 14+ project with App Router 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
This creates the file below. init detects your project layout: a ./src/ directory (common in Next.js) yields ./src/types/cms.ts; otherwise (a Nuxt project or no src/) it uses ./types/cms.ts.
// tentocms.config.js
export default {
apiKey: process.env.TENTO_API_KEY,
apiUrl: process.env.TENTO_BASE_URL,
output: './src/types/cms.ts',
watch: {
interval: 30,
},
}
2. Add Environment Variables
Create or update your .env.local file:
# .env.local
TENTO_API_KEY=your_api_key_here
TENTO_BASE_URL=https://tento-api.intelligentlending.co.uk
Never prefix the API key with NEXT_PUBLIC_ — that would expose it in the browser bundle. The base URL is safe to expose if needed.
Add to .gitignore:
# .gitignore
.env*.local
generate loads a .env file into process.env before reading TENTO_API_KEY / TENTO_BASE_URL:
# Auto-loads ./.env if present
tentocms-typegen generate
# Or point at an explicit file (e.g. Next.js uses .env.local)
tentocms-typegen generate -e ./.env.local
-e, --env <path>loads the given file (errors if it's missing). With no flag,./.envis auto-loaded if present.- Real environment variables are never overridden — existing
process.envvalues always win, sonode --env-file=.env ...keeps working.
Node ≥ 22/24: a bare
--env-fileis reserved by Node 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": "next dev",
"build": "next 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 creates ./src/types/cms.ts with your page types, collections, and component types.
Usage with App Router
Server Component (Recommended)
Fetch data directly in Server Components — process.env.TENTO_API_KEY is server-only and never sent to the browser:
// app/blog/[slug]/page.tsx
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '@/types/cms'
// The REST API returns `Page<BlogPostFields>` directly for this endpoint —
// `name`/`fields`/`seo` at the top level, custom content under `fields`.
async function getPage(slug: string): Promise<Page<BlogPostFields>> {
const res = await fetch(
`${process.env.TENTO_BASE_URL}/api/v1/pages/${slug}`,
{
headers: {
'X-API-Key': process.env.TENTO_API_KEY!,
},
next: { revalidate: 60 }, // ISR: Revalidate every 60 seconds
}
)
if (!res.ok) {
throw new Error('Failed to fetch page')
}
return res.json()
}
export default async function BlogPostPage({
params,
}: {
params: { slug: string }
}) {
const page = await getPage(params.slug)
return (
<article>
<h1>{page.name}</h1>
{/* Custom fields live under page.fields */}
<div className="meta">
<time>{page.fields.publishedDate}</time>
<span>{page.fields.author.name}</span>
</div>
<div dangerouslySetInnerHTML={{ __html: page.fields.body }} />
{/* Render components — match on comp._type (kebab-case discriminant) */}
{page.fields.components.map((comp, index) => (
<ComponentRenderer key={index} component={comp} />
))}
</article>
)
}
// Generate static params for SSG
export async function generateStaticParams() {
const res = await fetch(
`${process.env.TENTO_BASE_URL}/api/v1/pages`,
{
headers: {
'X-API-Key': process.env.TENTO_API_KEY!,
},
}
)
const pages: Page<BlogPostFields>[] = await res.json()
return pages.map((page) => ({
slug: page.slug,
}))
}
Fetching Collections
// app/team/page.tsx
import type { TeamMemberItem } from '@/types/cms'
async function getTeamMembers(): Promise<TeamMemberItem[]> {
const res = await fetch(
`${process.env.TENTO_BASE_URL}/api/v1/collections/team-members`,
{
headers: {
'X-API-Key': process.env.TENTO_API_KEY!,
},
next: { revalidate: 3600 }, // Revalidate every hour
}
)
if (!res.ok) {
throw new Error('Failed to fetch team members')
}
return res.json()
}
export default async function TeamPage() {
const team = await getTeamMembers()
return (
<div className="team-grid">
{team.map((member) => (
<div key={member.id} className="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>
)
}
Creating a Data Access Layer
Create a centralised API client for cleaner code. Keep it in a server-only module so the key is never bundled for the client:
// lib/tentocms.ts
// Pass a concrete type per call (e.g. getPage<Page<BlogPostFields>>(...)).
// The generator does not emit a page-envelope wrapper itself — `Page` comes
// from `@tentocms/client` — so these helpers default to `unknown` and let
// each call site supply the exact type.
// These are server-only env vars — no NEXT_PUBLIC_ prefix
const API_URL = process.env.TENTO_BASE_URL!
const API_KEY = process.env.TENTO_API_KEY!
interface FetchOptions {
revalidate?: number | false
tags?: string[]
}
async function tentoFetch<T>(
endpoint: string,
options?: FetchOptions
): Promise<T> {
const res = await fetch(`${API_URL}${endpoint}`, {
headers: {
'X-API-Key': API_KEY,
},
next: {
revalidate: options?.revalidate,
tags: options?.tags,
},
})
if (!res.ok) {
throw new Error(`TentoCMS API error: ${res.statusText}`)
}
return res.json()
}
export const tentocms = {
// Fetch a page by slug with type safety
getPage: <T = unknown>(slug: string, options?: FetchOptions) =>
tentoFetch<T>(`/api/v1/pages/${slug}`, options),
// Fetch all pages of a specific type
getPages: <T = unknown[]>(pageTypeSlug: string, options?: FetchOptions) =>
tentoFetch<T>(`/api/v1/pages?pageType=${pageTypeSlug}`, options),
// Fetch collection entries
getCollection: <T = any[]>(collectionSlug: string, options?: FetchOptions) =>
tentoFetch<T>(`/api/v1/collections/${collectionSlug}`, options),
}
Usage:
// app/blog/[slug]/page.tsx
import { tentocms } from '@/lib/tentocms'
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '@/types/cms'
export default async function BlogPostPage({
params,
}: {
params: { slug: string }
}) {
const page = await tentocms.getPage<Page<BlogPostFields>>(params.slug, {
revalidate: 60,
tags: ['blog-post', params.slug],
})
return (
<article>
<h1>{page.name}</h1>
<div dangerouslySetInnerHTML={{ __html: page.fields.body }} />
</article>
)
}
Dynamic Component Rendering
// components/ComponentRenderer.tsx
import type { AnyComponent } from '@/types/cms'
import Hero from './Hero'
import TextBlock from './TextBlock'
import ImageGallery from './ImageGallery'
import CallToAction from './CallToAction'
// Keys match the kebab-case _type discriminant from the API
const componentMap = {
'hero': Hero,
'text-block': TextBlock,
'image-gallery': ImageGallery,
'cta-banner': CallToAction,
} as const
interface ComponentRendererProps {
component: AnyComponent
}
export default function ComponentRenderer({ component }: ComponentRendererProps) {
// Match on component._type, not component.type
const ComponentToRender = componentMap[component._type as keyof typeof componentMap]
if (!ComponentToRender) {
console.warn(`Unknown component type: ${component._type}`)
return null
}
// Component content fields are at the component root — spread directly
return <ComponentToRender {...component} />
}
Client Component with SWR
For client-side data fetching, proxy requests through a Route Handler to keep the API key server-side:
// app/api/cms/pages/[slug]/route.ts
import { NextRequest } from 'next/server'
export async function GET(
_req: NextRequest,
{ params }: { params: { slug: string } }
) {
const res = await fetch(
`${process.env.TENTO_BASE_URL}/api/v1/pages/${params.slug}`,
{
headers: { 'X-API-Key': process.env.TENTO_API_KEY! },
}
)
if (!res.ok) {
return Response.json({ error: 'Not found' }, { status: res.status })
}
// The REST API wraps the page in an envelope ({ data, redirect }); return just the page.
const { data } = await res.json()
return Response.json(data)
}
Add a matching Route Handler for collections so useCollection resolves (the hooks below call it):
// app/api/cms/collections/[slug]/route.ts
import { NextRequest } from 'next/server'
export async function GET(
_req: NextRequest,
{ params }: { params: { slug: string } }
) {
const res = await fetch(
`${process.env.TENTO_BASE_URL}/api/v1/collections/${params.slug}`,
{
headers: { 'X-API-Key': process.env.TENTO_API_KEY! },
}
)
if (!res.ok) {
return Response.json({ error: 'Not found' }, { status: res.status })
}
// Collections wrap items in an envelope; `data` is an array (or a single item for
// singletons) — normalise to an array.
const { data } = await res.json()
return Response.json(Array.isArray(data) ? data : [data])
}
npm install swr
// hooks/useTentoCMS.ts
'use client'
import useSWR from 'swr'
import type { Page } from '@tentocms/client'
import type { BlogPostFields, TeamMemberItem } from '@/types/cms'
// Fetches from your Next.js Route Handler — no API key in the browser
const fetcher = (url: string) => fetch(url).then((res) => res.json())
export function usePage<T = Page<BlogPostFields>>(slug: string) {
return useSWR<T>(`/api/cms/pages/${slug}`, fetcher)
}
export function useCollection<T = TeamMemberItem[]>(collectionSlug: string) {
return useSWR<T>(`/api/cms/collections/${collectionSlug}`, fetcher)
}
Usage in a Client Component:
// components/RecentPosts.tsx
'use client'
import { useCollection } from '@/hooks/useTentoCMS'
import type { BlogPostContent } from '@/types/cms'
export default function RecentPosts() {
// `useCollection` returns flat collection items — unlike `usePage`, there's
// no `Page<T>` envelope here, so `BlogPostContent`'s own flat shape
// (`id`, `slug`, `title`, `seo?`, plus custom fields at the top level) applies directly.
const { data: posts, error, isLoading } = useCollection<BlogPostContent[]>('blog-posts')
if (isLoading) return <div>Loading...</div>
if (error) return <div>Failed to load posts</div>
return (
<div>
{posts?.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}
Typing page.fields — *Fields vs *Content
For each 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 are top-level, seo lives under page.seo). Use *Fields to type page.fields directly:
import type { BlogPostFields } from '@/types/cms'
const fields: BlogPostFields = page.fields // exact custom-field shape
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 for back-compat.
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, while the same field modelled as a plain object elsewhere becomesRecord<string, unknown>. An untyped repeater (no sub-fields defined) falls back toRecord<string, unknown>[]. The generator reflects the source schema faithfully — align the field definitions in your TentoCMS schema for consistent, fully-typed output.
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
- Use
revalidatePath()orrevalidateTag()to update cached content
On-Demand Revalidation
Use Next.js 14's revalidation features to update content:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret')
// Verify webhook secret
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 })
}
const body = await request.json()
const { path, tag } = body
try {
if (path) {
revalidatePath(path)
}
if (tag) {
revalidateTag(tag)
}
return Response.json({ revalidated: true, now: Date.now() })
} catch (err) {
return Response.json(
{ message: 'Error revalidating', error: err },
{ status: 500 }
)
}
}
Configure webhook in TentoCMS to call this endpoint when content is published.
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
output: './src/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.env.localfile - 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
Environment Variables Not Found
In Next.js, server-only variables must NOT have the NEXT_PUBLIC_ prefix:
# .env.local
TENTO_API_KEY=secret_key_here # Server-only (never use NEXT_PUBLIC_ for this)
TENTO_BASE_URL=https://tento-api.intelligentlending.co.uk # Server-only
Build Fails with Type Errors
Ensure types are generated before building:
{
"scripts": {
"prebuild": "npm run types:generate",
"build": "next build"
}
}
Next Steps
- Read the Next.js Integration Guide for preview mode and revalidation patterns
- Browse the SDK Reference for the full client API surface
- Learn about ISR and Caching

