TentoCMS
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-route params is a Promise — 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

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.

Resources

Copyright © 2026