TentoCMS
Sdk

Astro

Using @tentocms/client with Astro.

Complete guide for integrating TentoCMS with Astro.

Verified against Astro 5.

Installation

pnpm add @tentocms/client

Environment Variables

Create .env:

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 src/lib/tento.ts:

import { TentoClient } from '@tentocms/client'

export const tento = new TentoClient({
  apiKey: import.meta.env.TENTO_API_KEY,
  baseUrl: import.meta.env.TENTO_BASE_URL,
})

export const tentoPreview = new TentoClient({
  apiKey: import.meta.env.TENTO_API_KEY,
  previewKey: import.meta.env.TENTO_PREVIEW_KEY,
  baseUrl: import.meta.env.TENTO_BASE_URL,
})

Basic Usage

Static Pages

---
// src/pages/index.astro
import { tento } from '../lib/tento'

const page = await tento.pages.getBySlug('homepage')
---

<html>
  <head>
    <title>{page.fields.title}</title>
  </head>
  <body>
    <h1>{page.fields.title}</h1>
    <div set:html={page.fields.content} />
  </body>
</html>

Dynamic Routes

---
// src/pages/[slug].astro
import { tento } from '../lib/tento'

export async function getStaticPaths() {
  const pages = await tento.pages.list({ limit: 100 })

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

const { page } = Astro.props
---

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

Collections

---
// src/pages/products.astro
import { tento } from '../lib/tento'

const products = await tento.collections.list('products', {
  limit: 20,
  sort: '-createdAt'
})
---

<html>
  <body>
    <h1>Products</h1>
    <div class="grid">
      {products.data.map((product) => (
        <div>
          <h3>{product.name}</h3>
          <p>${product.price}</p>
        </div>
      ))}
    </div>
  </body>
</html>

Blog Posts

---
// src/pages/blog/index.astro
import { tento } from '../../lib/tento'

const posts = await tento.blog.posts.list({
  limit: 10,
  sort: '-publishedAt'
})
---

<html>
  <body>
    <h1>Blog</h1>
    {posts.data.map((post) => (
      <article>
        <a href={`/blog/${post.slug}`}>
          <h2>{post.title}</h2>
        </a>
        <p>{post.excerpt}</p>
        <time>{new Date(post.publishedAt).toLocaleDateString()}</time>
      </article>
    ))}
  </body>
</html>

Dynamic Blog Routes

---
// src/pages/blog/[slug].astro
import { tento } from '../../lib/tento'

export async function getStaticPaths() {
  // `list()` omits `content` by default, so build the paths from the list
  // and fetch each full post (with body content) in the page frontmatter.
  const posts = await tento.blog.posts.list({ limit: 100 })

  return posts.data.map((post) => ({
    params: { slug: post.slug },
  }))
}

// getBySlug returns the full post, including `content`
const post = await tento.blog.posts.getBySlug(Astro.params.slug!)
---

<html>
  <body>
    <article>
      <h1>{post.title}</h1>
      {post.featuredImage && (
        <img src={post.featuredImage._url} alt={post.featuredImage._altText} />
      )}
      <div set:html={post.content} />
      <div class="meta">
        {post.author && <p>By {post.author.name}</p>}
        <time>{new Date(post.publishedAt).toLocaleDateString()}</time>
      </div>
      {post.category && (
        <a href={`/blog/category/${post.category.slug}`}>
          {post.category.name}
        </a>
      )}
      {post.tags.map((tag) => (
        <a href={`/blog/tag/${tag.slug}`}>{tag.name}</a>
      ))}
    </article>
  </body>
</html>

Preview Mode (SSR)

Astro requires on-demand (server) rendering for preview mode. Astro 5 removed the output: 'hybrid' mode: keep the default output: 'static' and opt individual routes into server rendering with export const prerender = false, plus a server adapter in astro.config.mjs:

import node from '@astrojs/node'

export default defineConfig({
  output: 'static', // default; per-route `export const prerender = false` renders on demand
  adapter: node({ mode: 'standalone' }), // or vercel(), netlify(), etc.
})

Create Preview Route

---
// src/pages/preview/[...slug].astro
export const prerender = false // Disable prerendering for this route

import { tentoPreview } from '../../lib/tento'

const { slug } = Astro.params
const secret = Astro.url.searchParams.get('secret')

// Validate secret
if (secret !== import.meta.env.PREVIEW_SECRET) {
  return Astro.redirect('/404')
}

// Fetch draft content
const page = await tentoPreview.pages.getBySlug(slug!)
---

<html>
  <body>
    <div class="preview-banner">
      ⚠️ Preview Mode - Viewing Draft Content
    </div>

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

Usage

Access preview mode:

https://yoursite.com/preview/homepage?secret=your-secret

TypeScript

Type-Safe Content

---
import { tento } from '../lib/tento'
import type { Page } from '@tentocms/client'

interface HomepageContent {
  heroTitle: string
  heroSubtitle: string
  features: Array<{
    title: string
    description: string
  }>
}

const page = await tento.pages.getBySlug<HomepageContent>('homepage')
---

<html>
  <body>
    <h1>{page.fields.heroTitle}</h1>
    <p>{page.fields.heroSubtitle}</p>
    <div>
      {page.fields.features.map((feature) => (
        <div>
          <h3>{feature.title}</h3>
          <p>{feature.description}</p>
        </div>
      ))}
    </div>
  </body>
</html>

Import Types

import type {
  Page,
  CollectionItem,
  BlogPost,
  MediaItem,
} from '@tentocms/client'

Components

Create reusable Astro components:

---
// src/components/PageContent.astro
import type { Page } from '@tentocms/client'

interface Props {
  page: Page
}

const { page } = Astro.props
---

<article>
  <h1>{page.fields.title}</h1>
  <div set:html={page.fields.content} />
</article>

Use in pages:

---
import { tento } from '../lib/tento'
import PageContent from '../components/PageContent.astro'

const page = await tento.pages.getBySlug('about')
---

<html>
  <body>
    <PageContent page={page} />
  </body>
</html>

Error Handling

---
import { TentoNotFoundError } from '@tentocms/client'
import { tento } from '../lib/tento'

let page
try {
  page = await tento.pages.getBySlug(Astro.params.slug!)
} catch (error) {
  if (error instanceof TentoNotFoundError) {
    return Astro.redirect('/404')
  }
  throw error
}
---

<html>
  <body>
    <h1>{page.fields.title}</h1>
  </body>
</html>

Image Optimization

Use Astro's Image component with TentoCMS media:

---
import { Image } from 'astro:assets'
import { tento } from '../lib/tento'

const media = await tento.media.getById('media-id')
---

<Image
  src={tento.media.getImageUrl(media.id, {
    width: 800,
    format: 'webp',
    quality: 90
  })}
  width={800}
  height={600}
  alt={media.altText || ''}
/>

Pagination

---
// src/pages/blog/[...page].astro
import { tento } from '../../lib/tento'

export async function getStaticPaths({ paginate }) {
  const posts = await tento.blog.posts.list({ limit: 100 })

  return paginate(posts.data, { pageSize: 10 })
}

const { page } = Astro.props
---

<html>
  <body>
    <h1>Blog - Page {page.currentPage}</h1>
    {page.data.map((post) => (
      <article>
        <h2>{post.title}</h2>
        <a href={`/blog/${post.slug}`}>Read More</a>
      </article>
    ))}

    <div class="pagination">
      {page.url.prev && <a href={page.url.prev}>Previous</a>}
      {page.url.next && <a href={page.url.next}>Next</a>}
    </div>
  </body>
</html>

Server-Side Rendering

For dynamic content, use SSR mode:

// astro.config.mjs
export default defineConfig({
  output: 'server', // Full SSR
  adapter: node(), // or vercel(), netlify(), etc.
})

Then disable prerendering per page:

---
export const prerender = false

import { tento } from '../lib/tento'

// This fetches on every request
const page = await tento.pages.getBySlug('dynamic-page')
---

Content Collections

Integrate TentoCMS with Astro Content Collections:

// src/content/config.ts
import { defineCollection, z } from 'astro:content'

const blogCollection = defineCollection({
  type: 'data',
  schema: z.object({
    title: z.string(),
    excerpt: z.string(),
    publishedAt: z.string(),
  }),
})

export const collections = {
  blog: blogCollection,
}

Fetch and use:

---
import { getCollection } from 'astro:content'
import { tento } from '../lib/tento'

// Fetch from TentoCMS
const tentoPosts = await tento.blog.posts.list({ limit: 10 })

// Or use Astro content collections
const localPosts = await getCollection('blog')
---

<html>
  <body>
    <h1>All Posts</h1>
    {/* Render TentoCMS posts */}
    {tentoPosts.data.map(post => (
      <article>
        <h2>{post.title}</h2>
      </article>
    ))}
  </body>
</html>

Best Practices

1. Use Static Generation by Default

---
// ✅ Good: Static generation (fast, cacheable)
const page = await tento.pages.getBySlug('homepage')
---

2. Enable SSR Only When Needed

---
// Only disable prerendering for truly dynamic content
export const prerender = false
---

3. Optimize Images

---
import { tento } from '../lib/tento'

const imageUrl = tento.media.getImageUrl('image-id', {
  width: 800,
  format: 'webp',
  quality: 90
})
---

<img src={imageUrl} alt="Optimized image" />

4. Use Environment Variables

---
// ✅ Good
const apiKey = import.meta.env.TENTO_API_KEY

// ❌ Bad
const apiKey = 'tento_pk_1234567890'
---

5. Implement Error Boundaries

Always handle errors gracefully to prevent build failures.

Build Hooks

Configure build hooks for automatic rebuilds:

# In your hosting provider (Netlify, Vercel, etc.)
curl -X POST 'https://api.netlify.com/build_hooks/YOUR_HOOK_ID'

Configure webhook in TentoCMS admin to trigger builds on content changes.

Resources

Copyright © 2026