Astro
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 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 (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,./.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": "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
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
- 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
- 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
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
- Read the Astro Integration Guide for preview mode and SSR patterns
- Browse the SDK Reference for the full client API surface
- Learn about Astro Islands

