TentoCMS
Typegen

Astro

Generating typed content from your schemas in an Astro project.

This guide shows you how to integrate TentoCMS type generation into your Astro 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

This creates the file below. init detects your project layout: a ./src/ directory (standard in Astro) 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 file:

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

Never prefix the API key with PUBLIC_ — that would expose it in the browser bundle. The base URL is safe to expose if needed.

Add to .gitignore:

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

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
tentocms-typegen generate -e ./config/.env.cms
  • -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": "astro dev",
    "build": "astro 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 in Astro

Fetching Pages

Astro's server frontmatter runs at build time (or on the server for SSR) — the API key stays server-side:

---
// src/pages/blog/[slug].astro
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '../../types/cms'

const { slug } = Astro.params

// TENTO_API_KEY has no PUBLIC_ prefix — it is server-only
const response = await fetch(
  `${import.meta.env.TENTO_BASE_URL}/api/v1/pages/${slug}`,
  {
    headers: {
      'X-API-Key': import.meta.env.TENTO_API_KEY,
    },
  }
)

if (!response.ok) {
  return Astro.redirect('/404')
}

// The REST API returns `Page<BlogPostFields>` directly for this endpoint —
// `name`/`fields`/`seo` at the top level, custom content under `fields`.
const page: Page<BlogPostFields> = await response.json()
---

<article>
  <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 set:html={page.fields.body} />

  <!-- Render components — match on comp._type (kebab-case discriminant) -->
  {page.fields.components.map((comp) => (
    <Fragment>
      {comp._type === 'hero' && <Hero {...comp} />}
      {comp._type === 'text-block' && <TextBlock {...comp} />}
      {comp._type === 'image-gallery' && <ImageGallery {...comp} />}
    </Fragment>
  ))}
</article>

Static Paths for SSG

Generate static pages at build time:

---
// src/pages/blog/[slug].astro
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '../../types/cms'

export async function getStaticPaths() {
  const response = await fetch(
    `${import.meta.env.TENTO_BASE_URL}/api/v1/pages?pageType=blog`,
    {
      headers: {
        'X-API-Key': import.meta.env.TENTO_API_KEY,
      },
    }
  )

  const pages: Page<BlogPostFields>[] = await response.json()

  return pages.map((page) => ({
    params: { slug: page.slug },
    props: { page },
  }))
}

const { page } = Astro.props as { page: Page<BlogPostFields> }
---

<article>
  <h1>{page.name}</h1>
  <div set:html={page.fields.body} />
</article>

Fetching Collections

---
// src/pages/team.astro
import type { TeamMemberItem } from '../types/cms'

const response = await fetch(
  `${import.meta.env.TENTO_BASE_URL}/api/v1/collections/team-members`,
  {
    headers: {
      'X-API-Key': import.meta.env.TENTO_API_KEY,
    },
  }
)

const team: TeamMemberItem[] = await response.json()
---

<div class="team-grid">
  {team.map((member) => (
    <div 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>

Creating a Utility Module

Create a typed API client for reuse across your project. This module is imported only from server-side Astro files, so the key never reaches the browser:

// src/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.

// Server-only env vars — no PUBLIC_ prefix
const API_URL = import.meta.env.TENTO_BASE_URL
const API_KEY = import.meta.env.TENTO_API_KEY

async function tentoFetch<T>(endpoint: string): Promise<T> {
  const response = await fetch(`${API_URL}${endpoint}`, {
    headers: {
      'X-API-Key': API_KEY,
    },
  })

  if (!response.ok) {
    throw new Error(`TentoCMS API error: ${response.statusText}`)
  }

  return response.json()
}

export const tentocms = {
  // Fetch a page by slug with type safety
  getPage: <T = unknown>(slug: string) => tentoFetch<T>(`/api/v1/pages/${slug}`),

  // Fetch all pages of a specific type
  getPages: <T = unknown[]>(pageTypeSlug: string) =>
    tentoFetch<T>(`/api/v1/pages?pageType=${pageTypeSlug}`),

  // Fetch collection entries
  getCollection: <T = any[]>(collectionSlug: string) =>
    tentoFetch<T>(`/api/v1/collections/${collectionSlug}`),
}

Usage:

---
// src/pages/blog/[slug].astro
import { tentocms } from '../../lib/tentocms'
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '../../types/cms'

const { slug } = Astro.params
const page = await tentocms.getPage<Page<BlogPostFields>>(slug)
---

<article>
  <h1>{page.name}</h1>
  <div set:html={page.fields.body} />
</article>

Dynamic Component Rendering

Create component mapping for flexible content:

---
// src/components/ComponentRenderer.astro
import type { AnyComponent } from '../types/cms'
import Hero from './Hero.astro'
import TextBlock from './TextBlock.astro'
import ImageGallery from './ImageGallery.astro'
import CallToAction from './CallToAction.astro'

interface Props {
  component: AnyComponent
}

const { component } = Astro.props

// Keys are the kebab-case _type values from the API
const componentMap = {
  'hero': Hero,
  'text-block': TextBlock,
  'image-gallery': ImageGallery,
  'cta-banner': CallToAction,
}

// Match on component._type, not component.type
const ComponentToRender = componentMap[component._type as keyof typeof componentMap]
---

{ComponentToRender ? <ComponentToRender {...component} /> : null}

Usage in page:

---
import ComponentRenderer from '../../components/ComponentRenderer.astro'
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '../../types/cms'

const { slug } = Astro.params
const page = await tentocms.getPage<Page<BlogPostFields>>(slug)
---

<article>
  <h1>{page.name}</h1>

  {page.fields.components.map((comp) => (
    <ComponentRenderer component={comp} />
  ))}
</article>

Client-Side Hydration with Islands

For interactive client-side components, proxy the API through an Astro endpoint to keep the key server-side:

// src/pages/api/cms/pages/[slug].ts
import type { APIRoute } from 'astro'

export const GET: APIRoute = async ({ params }) => {
  if (!params.slug) {
    return new Response(JSON.stringify({ error: 'Missing slug' }), { status: 400 })
  }

  const response = await fetch(
    `${import.meta.env.TENTO_BASE_URL}/api/v1/pages/${params.slug}`,
    {
      headers: { 'X-API-Key': import.meta.env.TENTO_API_KEY },
    }
  )

  if (!response.ok) {
    return new Response(JSON.stringify({ error: 'Not found' }), { status: response.status })
  }

  // The REST API wraps the page in an envelope: { data, redirect }. Return just the page.
  const { data } = await response.json()
  return new Response(JSON.stringify(data), {
    headers: { 'Content-Type': 'application/json' },
  })
}

Use the endpoint from client-side framework components:

// src/components/InteractivePageLoader.tsx
import { useState, useEffect } from 'react'
import type { Page } from '@tentocms/client'
import type { BlogPostFields } from '../types/cms'

export default function InteractivePageLoader() {
  const [slug, setSlug] = useState('')
  const [page, setPage] = useState<Page<BlogPostFields> | null>(null)

  useEffect(() => {
    if (!slug) return

    // Calls the [slug] Astro API endpoint defined above — no API key in the browser
    fetch(`/api/cms/pages/${encodeURIComponent(slug)}`)
      .then((res) => (res.ok ? res.json() : null))
      .then(setPage)
  }, [slug])

  return (
    <div>
      <input
        type="search"
        value={slug}
        onChange={(e) => setSlug(e.target.value)}
        placeholder="Enter a page slug..."
      />
      {page && <h3>{page.name}</h3>}
    </div>
  )
}

Mount with client directive:

---
import InteractivePageLoader from '../components/InteractivePageLoader'
---

<InteractivePageLoader client:load />

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. Astro rebuilds pages automatically

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

In Astro, server-only variables must NOT have the PUBLIC_ prefix:

# .env
TENTO_API_KEY=secret_key_here    # Server-only (never use PUBLIC_ for this)
TENTO_BASE_URL=https://tento-api.intelligentlending.co.uk  # Server-only

Access server variables with import.meta.env.TENTO_API_KEY (available only in server-side Astro files and API endpoints).

Build Fails with Type Errors

Ensure types are generated before building:

{
  "scripts": {
    "prebuild": "npm run types:generate",
    "build": "astro build"
  }
}

Next Steps

Copyright © 2026