Sdk
Next.js
Using @tentocms/client with Next.js.
Complete guide for integrating TentoCMS with Next.js App Router and Pages Router.
Verified against Next.js 15. In the App Router,
draftMode()is async and dynamic-routeparamsis aPromise— both must be awaited (shown below).
Installation
pnpm add @tentocms/client
Environment Variables
Create .env.local:
TENTO_API_KEY=tento_pk_1234567890
TENTO_PREVIEW_KEY=preview_abc123def456789...
TENTO_BASE_URL=https://tento-api.intelligentlending.co.uk
PREVIEW_SECRET=your-random-secret-string
App Router (Recommended)
Setup
Create lib/tento.ts:
import { TentoClient } from '@tentocms/client'
export const tento = new TentoClient({
apiKey: process.env.TENTO_API_KEY!,
baseUrl: process.env.TENTO_BASE_URL,
})
export const tentoPreview = new TentoClient({
apiKey: process.env.TENTO_API_KEY!,
previewKey: process.env.TENTO_PREVIEW_KEY,
baseUrl: process.env.TENTO_BASE_URL,
})
Basic Usage
Static Page
// app/page.tsx
import { tento } from '@/lib/tento'
export default async function HomePage() {
const page = await tento.pages.getBySlug('homepage')
return (
<main>
<h1>{page.fields.title as string}</h1>
<div dangerouslySetInnerHTML={{ __html: page.fields.content as string }} />
</main>
)
}
Dynamic Routes
// app/[slug]/page.tsx
import { tento } from '@/lib/tento'
import { notFound } from 'next/navigation'
export async function generateStaticParams() {
const pages = await tento.pages.list({ limit: 100 })
return pages.data.map((page) => ({
slug: page.slug,
}))
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
try {
const page = await tento.pages.getBySlug(slug)
return (
<main>
<h1>{page.fields.title as string}</h1>
<div dangerouslySetInnerHTML={{ __html: page.fields.content as string }} />
</main>
)
} catch (error) {
notFound()
}
}
Collections
// app/products/page.tsx
import { tento } from '@/lib/tento'
export default async function ProductsPage() {
const products = await tento.collections.list('products', {
limit: 20,
sort: '-createdAt'
})
// `data` is an array for normal collections, or a single item for singletons —
// normalise to an array before mapping.
const items = Array.isArray(products.data) ? products.data : [products.data]
return (
<div className="grid grid-cols-3 gap-4">
{items.map((product) => (
<div key={product.slug as string}>
<h3>{product.name as string}</h3>
<p>${product.price as number}</p>
</div>
))}
</div>
)
}
Blog Posts
// app/blog/page.tsx
import { tento } from '@/lib/tento'
import Link from 'next/link'
export default async function BlogPage() {
const posts = await tento.blog.posts.list({
limit: 10,
sort: '-publishedAt'
})
return (
<div>
<h1>Blog</h1>
<div className="space-y-4">
{posts.data.map((post) => (
<article key={post.id}>
<Link href={`/blog/${post.slug}`}>
<h2>{post.title}</h2>
</Link>
<p>{post.excerpt}</p>
<time>{post.publishedAt}</time>
</article>
))}
</div>
</div>
)
}
Preview Mode
Create Preview API Route
Create app/api/preview/route.ts:
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const secret = searchParams.get('secret')
const slug = searchParams.get('slug')
// Validate secret
if (secret !== process.env.PREVIEW_SECRET) {
return new Response('Invalid secret', { status: 401 })
}
if (!slug) {
return new Response('Missing slug', { status: 400 })
}
// Enable draft mode (draftMode() is async in Next.js 15)
;(await draftMode()).enable()
// Redirect to the page
redirect(`/${slug}`)
}
Create Exit Preview Route
Create app/api/exit-preview/route.ts:
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'
export async function GET() {
;(await draftMode()).disable()
redirect('/')
}
Use Preview Mode in Pages
// app/[slug]/page.tsx
import { draftMode } from 'next/headers'
import { tento, tentoPreview } from '@/lib/tento'
import { notFound } from 'next/navigation'
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const { isEnabled } = await draftMode()
const client = isEnabled ? tentoPreview : tento
try {
const page = await client.pages.getBySlug(slug)
return (
<main>
{isEnabled && (
<div className="bg-yellow-400 p-4 text-center">
⚠️ Preview Mode - Viewing Draft Content
<a href="/api/exit-preview" className="ml-4 underline">
Exit Preview
</a>
</div>
)}
<h1>{page.fields.title as string}</h1>
<div dangerouslySetInnerHTML={{ __html: page.fields.content as string }} />
</main>
)
} catch (error) {
notFound()
}
}
TypeScript
// types/content.ts
export interface HomepageContent {
heroTitle: string
heroSubtitle: string
heroImage: string
features: Array<{
title: string
description: string
}>
}
// app/page.tsx
import { tento } from '@/lib/tento'
import type { HomepageContent } from '@/types/content'
export default async function HomePage() {
const page = await tento.pages.getBySlug<HomepageContent>('homepage')
return (
<main>
<h1>{page.fields.heroTitle}</h1>
<p>{page.fields.heroSubtitle}</p>
{page.fields.features.map((feature, i) => (
<div key={i}>
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</div>
))}
</main>
)
}
Caching & Revalidation
Time-based Revalidation
// Revalidate every 60 seconds
export const revalidate = 60
export default async function HomePage() {
const page = await tento.pages.getBySlug('homepage')
return <div>{page.fields.title as string}</div>
}
On-demand Revalidation
Create app/api/revalidate/route.ts:
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret')
// Validate secret
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 })
}
const { path } = await request.json()
try {
revalidatePath(path)
return Response.json({ revalidated: true })
} catch (err) {
return Response.json({ message: 'Error revalidating' }, { status: 500 })
}
}
Trigger from TentoCMS webhook:
curl -X POST 'https://yoursite.com/api/revalidate?secret=TOKEN' \
-H 'Content-Type: application/json' \
-d '{"path":"/blog"}'
Error Handling
import { TentoNotFoundError, TentoAuthenticationError } from '@tentocms/client'
import { notFound } from 'next/navigation'
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
try {
const page = await tento.pages.getBySlug(slug)
return <div>{page.fields.title as string}</div>
} catch (error) {
if (error instanceof TentoNotFoundError) {
notFound()
}
if (error instanceof TentoAuthenticationError) {
throw new Error('Invalid API key')
}
throw error
}
}
Pages Router
Setup
Same setup as App Router - create lib/tento.ts.
Basic Usage
// pages/index.tsx
import { GetStaticProps } from 'next'
import { tento } from '@/lib/tento'
import type { Page } from '@tentocms/client'
interface Props {
page: Page
}
export const getStaticProps: GetStaticProps<Props> = async () => {
const page = await tento.pages.getBySlug('homepage')
return {
props: { page },
revalidate: 60, // ISR: revalidate every 60 seconds
}
}
export default function HomePage({ page }: Props) {
return (
<main>
<h1>{page.fields.title as string}</h1>
<div dangerouslySetInnerHTML={{ __html: page.fields.content as string }} />
</main>
)
}
Dynamic Routes
// pages/[slug].tsx
import { GetStaticPaths, GetStaticProps } from 'next'
import { tento } from '@/lib/tento'
import type { Page } from '@tentocms/client'
export const getStaticPaths: GetStaticPaths = async () => {
const pages = await tento.pages.list({ limit: 100 })
const paths = pages.data.map((page) => ({
params: { slug: page.slug },
}))
return {
paths,
fallback: 'blocking',
}
}
export const getStaticProps: GetStaticProps = async ({ params }) => {
const page = await tento.pages.getBySlug(params!.slug as string)
if (!page) {
return { notFound: true }
}
return {
props: { page },
revalidate: 60,
}
}
export default function Page({ page }: { page: Page }) {
return (
<main>
<h1>{page.fields.title as string}</h1>
</main>
)
}
Preview Mode (Pages Router)
// pages/api/preview.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const { secret, slug } = req.query
// Validate secret
if (secret !== process.env.PREVIEW_SECRET) {
return res.status(401).json({ message: 'Invalid secret' })
}
// Enable preview mode
res.setPreviewData({})
// Redirect to the page
res.redirect(`/${slug ?? ''}`)
}
Exit preview:
// pages/api/exit-preview.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
res.clearPreviewData()
res.redirect('/')
}
Use in pages:
// pages/[slug].tsx
export const getStaticProps: GetStaticProps = async ({ params, preview }) => {
const client = preview ? tentoPreview : tento
const page = await client.pages.getBySlug(params!.slug as string)
return {
props: { page, preview: !!preview },
revalidate: 60,
}
}
export default function Page({ page, preview }: { page: Page; preview: boolean }) {
return (
<main>
{preview && (
<div className="bg-yellow-400 p-4">
Preview Mode - <a href="/api/exit-preview">Exit</a>
</div>
)}
<h1>{page.fields.title as string}</h1>
</main>
)
}
Best Practices
1. Use Environment Variables
Never hardcode API keys:
// ✅ Good
const tento = new TentoClient({
apiKey: process.env.TENTO_API_KEY!
})
// ❌ Bad
const tento = new TentoClient({
apiKey: 'tento_pk_1234567890'
})
2. Implement Proper Error Handling
try {
const page = await tento.pages.getBySlug(slug)
return { props: { page } }
} catch (error) {
if (error instanceof TentoNotFoundError) {
return { notFound: true }
}
throw error
}
3. Use ISR for Dynamic Content
export const getStaticProps: GetStaticProps = async () => {
const page = await tento.pages.getBySlug('homepage')
return {
props: { page },
revalidate: 60 // Regenerate page every 60 seconds
}
}
4. Optimize Images
import Image from 'next/image'
<Image
src={tento.media.getImageUrl(imageId, {
width: 800,
height: 600,
format: 'webp'
})}
width={800}
height={600}
alt="Product"
/>
5. Use TypeScript for Type Safety
Always define content types for better development experience.

