TentoCMS
Typegen

Next.js

Generating typed content from your schemas in a Next.js project.

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 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

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, ./.env is auto-loaded if present.
  • Real environment variables are never overridden — existing process.env values always win, so node --env-file=.env ... keeps working.

Node ≥ 22/24: a bare --env-file is 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

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

  • 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, while the same field modelled as a plain object elsewhere becomes Record<string, unknown>. An untyped repeater (no sub-fields defined) falls back to Record<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).

  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
  5. Use revalidatePath() or revalidateTag() 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

  1. Check that TENTO_API_KEY is set in your .env.local 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

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

Copyright © 2026