TentoCMS
Guides

ButterCMS Migration

Step-by-step migration of a ButterCMS application to TentoCMS.

Verified against commit 6dbe274 (2026-07-21). See Step 0 to confirm the live deployment matches.

This guide covers the application-level code changes required when migrating a frontend codebase from ButterCMS to TentoCMS. It does not cover content import — use the TentoCMS migration wizard for that step.

Before You Start

Step 0: Verify the deployment matches this guide

Every behaviour documented below was verified against the commit in the line above. A deployment running an older build may not have those fixes — the symptom is a documented flag or field silently doing nothing, with no error to explain why. Before you rely on anything in this guide, confirm what is actually live:

curl -s https://tento-api.intelligentlending.co.uk/health
# → { "status": "healthy", "version": "0.0.1", "commit": "<sha>", "environment": "production" }

Check the commit value against the "Verified against" commit at the top of this guide:

  • commit is at or after the verified SHA → you're good; proceed.
  • commit is behind it (or reports "unknown") → the deployment predates this guide. Request a redeploy of @tentocms/api before migrating, or expect discrepancies. If in doubt, ask the Tento team which commit is live and whether it includes the fixes you need.

Why this matters: the guide pins a commit, but only the running worker knows what it actually serves. A fix merged to main is not live until that environment is redeployed. The /health commit field is the authoritative answer.

Prerequisites

  • Content already imported via the TentoCMS migration wizard (run a dry run first)
  • @tentocms/client installed in your project
  • TentoCMS API key available (public key tento_pk_* for read-only, secret key tento_sk_* for writes)
  • Your TentoCMS API base URL. For this deployment it is https://tento-api.intelligentlending.co.uk. You must pass it to the client — see SDK Setup.
  • Optionally: generated TypeScript types via @tentocms/typegen (see Generating types)

Scope

This guide covers:

  • SDK setup and client initialisation
  • Data shape differences between ButterCMS and TentoCMS responses
  • Component identification changes (snake_case to kebab-case)
  • SDK method mapping
  • Image handling
  • Common gotchas discovered during real migrations

Migration Order

Follow this sequence to avoid breaking your application mid-migration:

  1. Schema — Create all component schemas in TentoCMS (verify completeness before proceeding)
  2. Content — Import content via the migration wizard (dry run, then full migration)
  3. Types — Generate TypeScript types with @tentocms/typegen if using TypeScript
  4. SDK install — Install @tentocms/client and initialise the client
  5. Application code — Update data fetching calls, field references, and component renderers
  6. Testing — Verify all pages render correctly and preview mode works

Starting application code changes before schema and content are complete leads to hybrid rendering complexity. See Partial schema migration in the gotchas section.


Migration Strategies

There are two ways to absorb the TentoCMS data shape; the right one depends on your app:

  • Propagate the new shape through your components — update renderers to read _type, page.seo, TentoMedia, etc. directly. This guide's examples take this approach. Best when you have few components or want the new shape end-to-end.
  • Adapt at your data-access boundary — if your app already centralises CMS access (e.g. Nuxt/Next server routes or a server/utils layer), translate the TentoCMS response back into the shape your components already consume, in one place. A real migration touched ~10 files instead of dozens, kept every component and its tests unchanged, and still handled every shape difference (kebab _type→snake, page.seo→custom meta, TentoMedia→URL string, _sortOrder ordering, name-filtering, pageType filtering). Best for apps with a CMS proxy layer and many components.

Both are valid — choose based on how your frontend already consumes content.

Adapting back to Butter's { type, fields } shape? Don't clobber a component's variant type. A component's own type field (e.g. a trustpilot block's type: 'carousel') sits alongside the kebab-case _type discriminant. If you naively set type = snakeCase(_type), you overwrite the variant. Instead, move every non-_type field into a nested fields object first (so the variant lands at fields.type), then derive type from _type.


SDK Setup

Replace the ButterCMS client initialisation with the TentoCMS client. Install the SDK from npm:

npm install @tentocms/client

Exact method signatures, options and return types live in the generated SDK API Reference — the canonical, always-in-sync surface for @tentocms/client. Reach for it whenever you need to confirm a method's shape while porting.

⚠️ Always set baseUrl explicitly. The SDK ships with a default host, but you should pass your deployment's API base URL so your app never depends on the packaged default. For this deployment it is https://tento-api.intelligentlending.co.uk. A TentoClient pointed at the wrong host fails with connection errors or 401s. Note that because a default exists, a missing baseUrl does not fail on this deployment — the packaged default URL equals this deployment's URL (https://tento-api.intelligentlending.co.uk), so a missing config is a silent no-op here. The misconfig only surfaces when porting to another environment where the packaged default is wrong. Set it explicitly so an absent env var surfaces immediately.

A note on naming. The npm packages are published under the @tentocms/* scope, the product is branded TentoCMS, and the live API for this deployment is tento-api.intelligentlending.co.uk. These all refer to the same system.

// ButterCMS
import Butter from 'buttercms'
const butter = Butter('your_api_token')

// TentoCMS
import { TentoClient } from '@tentocms/client'
const tento = new TentoClient({
  apiKey: 'tento_pk_...',
  baseUrl: 'https://tento-api.intelligentlending.co.uk', // set this explicitly
})

Store the API key and base URL in environment variables, not hardcoded in source:

// Recommended
const tento = new TentoClient({
  apiKey: process.env.TENTO_API_KEY!,
  baseUrl: process.env.TENTO_BASE_URL!,      // https://tento-api.intelligentlending.co.uk
  previewKey: process.env.TENTO_PREVIEW_KEY, // optional, for draft content
})
# .env
TENTO_API_KEY=tento_pk_...
TENTO_BASE_URL=https://tento-api.intelligentlending.co.uk
TENTO_PREVIEW_KEY=preview_...   # optional, only for previewing drafts

Framework Setup

The examples in this guide use React/JSX for brevity, but @tentocms/client is framework-agnostic. For framework-specific setup (typed composables, server utilities, caching, and preview wiring) see the dedicated guides:

Keep your key server-side. For SSR frameworks the secure default is to call the SDK from a server route or server utility and expose only the rendered data to the browser — not to register the client as a universal plugin that also runs client-side (which leaks the key into the client bundle).

// Nuxt — a server route keeps the key server-side: server/api/cms/page.get.ts
import { TentoClient, TentoNotFoundError } from '@tentocms/client'

const tento = new TentoClient({
  apiKey: process.env.TENTO_API_KEY!,
  baseUrl: process.env.TENTO_BASE_URL!,
})

export default defineEventHandler(async (event) => {
  const { slug, preview } = getQuery(event)
  // getBySlug throws TentoNotFoundError on 404 — catch it or a missing slug becomes a 500
  try {
    return await tento.pages.getBySlug(String(slug), { preview: preview === '1' })
  } catch (e) {
    if (!(e instanceof TentoNotFoundError)) throw e
    return null
  }
})
<!-- Vue renderer: flatten component fields, match on the kebab-case _type -->
<script setup lang="ts">
defineProps<{ components: Array<Record<string, unknown> & { _type: string }> }>()
</script>

<template>
  <!-- v-bind="c" spreads the component itself, NOT c.fields -->
  <component
    :is="componentFor(c._type)"
    v-for="(c, i) in components"
    :key="i"
    v-bind="c"
  />
</template>

Data Shape Differences

Pages

ButterCMS returns all page content inside page.fields. TentoCMS also uses page.fields for custom content, but promotes several identifiers to top-level properties.

// ButterCMS page response
{
  data: {
    slug: 'about-us',
    page_type: 'about-page',
    fields: {
      seo_title: 'About Us',
      meta_description: 'Learn about our company',
      headline: 'Welcome to Our Company',
      body: '<p>...</p>',
    }
  }
}

// TentoCMS page response
{
  id: '123e4567-e89b-12d3-a456-426614174000',
  name: 'About Us',            // clean title — Butter's admin name field never included a prefix
  slug: 'about-us',
  type: 'about-page',          // page type slug, top-level
  fields: {                    // custom content only
    headline: 'Welcome to Our Company',
    body: '<p>...</p>',
  },
  seo: {                       // SEO fields nested under seo object
    metaTitle: 'About Us',
    metaDescription: 'Learn about our company',
    metaRobots: 'index',
    ogTitle: 'About Us',
    ogDescription: 'Learn about our company',
    ogImage: 'https://tento-api.intelligentlending.co.uk/api/v1/media/<id>/og.png',
    canonicalUrl: 'https://example.com/about-us',
  },
  publishedAt: '2026-01-15T10:00:00Z',
  updatedAt: '2026-02-01T14:30:00Z',
}

Key differences:

  • page.fields contains only your custom content fields — no SEO, no identifiers
  • page.type is the page type slug (top-level, not inside fields)
  • SEO metadata is under page.seo, not scattered through page.fields
  • id, publishedAt, and updatedAt are always top-level

page.seo exposes seven fields: metaTitle, metaDescription, metaRobots, ogTitle, ogDescription, ogImage, and canonicalUrl. Any of them may be undefined if unset on the content.

⚠️ seo.metaTitle may contain a leading type prefix — page.name will not. The importer copies ButterCMS's meta_title value verbatim into seo.metaTitle. ButterCMS users frequently set meta_title to strings like "Product Page: My Title" in the admin — that prefix is not stripped on import, so page.seo.metaTitle can come through as "Product Page: My Title" while page.name is the clean "My Title". If you render seo.metaTitle as your <title> tag, strip any leading "<Type>: " prefix in your adapter (or clean it in the TentoCMS admin post-import) — the importer does not do this automatically.

🔴 Custom SEO components are reshaped on import — audit before you migrate. Only the seven standard fields above populate page.seo. If your ButterCMS content stored SEO in a custom component (e.g. fields.meta = { meta_title, meta_description, meta_canonical, page_path }), the migration moves the recognised SEO fields out of fields into page.seo and leaves anything else behind. After import:

  • Recognised SEO fields (meta_title, meta_description, meta_canonical, …) are gone from fields.meta (now under page.seo), and page.seo is often only partially populated.
  • A non-SEO field (e.g. page_path) that is a real field in your Butter schema survives in fields.meta — even when empty. ButterCMS sends defined fields as "" when unpopulated (it does not omit them), and the importer keeps non-SEO sub-fields regardless of value, so a defined-but-empty page_path comes through as fields.meta = { page_path: "" }.
  • A container holding only recognised SEO fields is dropped entirelyfields.meta becomes null/undefined. So if you expected page_path but fields.meta is null, the cause is that page_path is not actually a defined field in your Butter meta schema — commonly a leftover/redundant property in your app's types that the CMS never sent (this is what BINQ hit; verify against your real source schema). To recover a routing path, use seo.canonicalUrl (preserved when meta_canonical was set) — BINQ used exactly this fallback.

This still bites: data.fields.meta being undefined means reading data.fields.meta.meta_canonical without optional chaining throws at SSR and 500s the page. (Even if fields.meta were to survive on some pages, a moved field like meta_canonical just reads undefined there — no crash, but no value either: read it from page.seo.)

Before migrating: audit every fields.<x> your app reads, and check it against your actual Butter schema — a field your app references but the CMS doesn't define (a redundant type) won't arrive, and if it was the container's only non-SEO field, fields.meta will be null. Read SEO from page.seo (or rebuild the old shape in a boundary adapter), and re-derive routing paths from seo.canonicalUrl rather than relying on fields.meta.page_path. Also audit reference fields (e.g. author, category): these are frequently unset on real imported content — an unset single-reference resolves to undefined, so reading fields.author.name without optional chaining SSR-500s. Apply the same optional-chaining / adapter-default discipline to reference reads as you would to fields.meta:

fields.meta = {
  meta_title: page.seo?.metaTitle,
  meta_description: page.seo?.metaDescription,
  meta_canonical: page.seo?.canonicalUrl,        // often empty after import
  // page_path: prefer seo.canonicalUrl. fields.meta?.page_path only exists if page_path is a
  // real (defined) field in your Butter meta schema — if it's a redundant app-side type, fields.meta is null.
  page_path: page.seo?.canonicalUrl ?? page.fields?.meta?.page_path,
}

For reference fields in a boundary adapter, coerce unset references to empty-string objects rather than leaving them undefined:

// Unset reference → undefined; provide a safe default so deep reads don't throw
const author = resolvedPage.fields.author ?? { name: '', bio: '' }
// Or use optional chaining everywhere the reference is read directly:
const authorName = resolvedPage.fields.author?.name

If you adopt the boundary-adapter strategy, always synthesise a non-null fields.meta object (rebuilt from page.seo), even when all values are undefined — so consumers that read fields.meta.meta_title without optional chaining don't SSR-500 on pages where the container was dropped entirely. Also default the individual SEO field values to '' (empty string), not null/undefined, to match ButterCMS's empty-string semantics. Tento returns seo.canonicalUrl: null (which becomes undefined after stripNulls) when unset; ButterCMS returned meta_canonical: "". Code like meta_canonical ?? '/' behaves differently on undefined vs ""?? fires on undefined but not on "". So a page that returned "" under ButterCMS (where ?? '/' left it unchanged) can silently start emitting / as its canonical under Tento (which returns undefined). (Note || and ?? differ here: "" is falsy, so || '/' would fire, but ?? '/' does not.) Normalise to '' in the adapter for parity.

Field presence in the target is driven by content, not the source schema. A field that exists in your ButterCMS schema but is empty (or absent) across every source page is simply not created in TentoCMS — there is no placeholder or empty field for it. Do not assume that every source field will appear in the imported schema; verify the live schema after import and rely on optional chaining for any field that may be absent.

How ButterCMS SEO fields map to page.seo

The importer auto-detects SEO in two places and maps a fixed set of names onto six of the sevenpage.seo fields. Top-level fields must carry a prefix; fields inside a recognised SEO container may use bare names. Recognised container keys (underscores optional, case-insensitive): meta, seo, seo_meta, meta_data, seo_data, seo_fields.

Source field name(s)page.seo
meta_title / seo_title — or bare title inside a containermetaTitle
meta_description / seo_description — or bare descriptionmetaDescription
meta_canonical / canonical_url — or bare canonicalcanonicalUrl
og_title / open_graph_titleogTitle
og_description / open_graph_descriptionogDescription
social_media_image / og_image / open_graph_image — or bare imageogImage

Notes:

  • Top-level wins: if a top-level field and a container sub-field both map to the same target, the top-level value is used.
  • metaRobots is never auto-mapped — pages default to index; set robots in TentoCMS after import if you need something else.
  • ogImage only captures ButterCMS CDN URLs (https://cdn.buttercms.com/...) so they resolve to migrated media; other image URLs are skipped.
  • Strings only — empty or non-string values are ignored.
  • Anything not in this table is not treated as SEO — it stays in your content as long as it's a real field in your Butter schema (it survives even when empty, since ButterCMS sends defined fields as ""). But a meta container left with only recognised SEO fields is dropped entirely, so a page_path that isn't actually defined in your schema (e.g. a redundant app-side type) won't be there — see the warning above. There's no auto-mapping for Twitter-card tags or arbitrary custom meta fields.

Other page-shape changes to verify

  • Page-type slugs are kebab-case: main_pagemain-page, legal_pagelegal-page, campaign_pagecampaign-page.
  • Verify the final page-type slugs after import. Slug normalisation and the typeless-pages catch-all (see below) both produce slugs that differ from your ButterCMS source names. A mismatched slug silently breaks any page.type === '...' discriminant, so confirm the imported slugs before wiring up routing. To enumerate the imported page types, call the public GET /api/v1/schemas endpoint (API-key auth). The response is wrapped: { data: { pageTypes, components, collections } } — read .data to reach the arrays. data.pageTypes[] lists every page type's slug and name regardless of publish state. (Listing pages — GET /api/v1/pages?limit=100 — also surfaces each page's type, but only for types that have at least one published page, so it under-reports types whose content imported as drafts; see Publish status of imported content.)

    🔴 Typeless ButterCMS pages land in a synthetic legacy-pages type — not a rename. ButterCMS allows pages to have no page_type (an empty string). TentoCMS requires every page to belong to a page type, so the importer buckets all such typeless pages into a synthetic page type named "Legacy Pages" (slug legacy-pages). A page appearing under legacy-pages was not renamed from something else — it simply had no page_type set in ButterCMS and landed in this catch-all.

    Watch out for heterogeneous typeless pages. The legacy-pages type's field schema is inferred from a single sample typeless page. If your typeless pages don't all share the same field structure, some fields will be missing or undefined on pages that don't match the sample. Discriminate on the top-level type field of the source content and treat legacy-pages as a mixed bucket — don't assume all items in it have the same shape.

    Use GET /api/v1/schemas to confirm the final slugs for every imported type, including legacy-pages if typeless pages were present in the source.

    💡 Use the Import report to see typeless-page counts and slug mappings. After running an import, the migration detail screen shows an Import report — see Verifying completeness with the Import report below.

    💡 Raw REST auth — use X-API-Key, not Authorization: Bearer. Any time this guide suggests calling an endpoint directly with curl or fetch, pass the key as the X-API-Key: <key> header (or ?api_key=<key> query param). The Authorization: Bearer <key> header is not recognised and returns 401 {"error":{"code":"UNAUTHORIZED","message":"API key required. Provide via X-API-Key header or api_key query param."}}. A read-only public key (tento_pk_*) is sufficient for schema enumeration and all other GET calls mentioned in this guide. This same header applies to the redirect handling and WAF-debugging calls described later.

Verifying completeness with the Import report

After running an import, open the migration detail screen in the TentoCMS admin UI. A new Import report panel summarises what was discovered, created, and skipped — check it before wiring up any application code so nothing is silently missed. The same data is available on the migration record at stats.report.

The report has three parts:

Schema completeness diff — broken down by kind (page types, components, collections):

  • Discovered — schemas found in the ButterCMS source.
  • Created — schemas imported into Tento.
  • Skipped — discovered in ButterCMS but not imported (e.g. a type you deselected, or one the importer could not map). Anything in this column represents a source type that is present in ButterCMS but absent in the target — worth reviewing before going live.

Slug mappings — every created schema's original source name alongside its final Tento slug. Entries where the slug was normalised (e.g. guide_page → guide-page, Legacy Pages → legacy-pages) are flagged explicitly. Use this table to learn the exact slugs to pass to GET /api/v1/schemas, pages.list({ pageType }), and collections.list().

Typeless pages — a count of ButterCMS pages that had no page_type and were imported under the legacy-pages catch-all bucket (see the legacy-pages note above). A non-zero count means legacy-pages is a mixed bucket — check its shape before consuming it.

💡 The Import report is the authoritative source for final slugs. GET /api/v1/schemas tells you what types exist now; the report tells you how each source name became that slug, which is what you need when updating discriminants like page.type === '...' in application code.

  • Top-level ButterCMS fields may move into fields (e.g. a brand field becomes fields.brand).
  • Watch for a redundant fields.page_type. A custom page_type content field can survive alongside the real top-level page.type, with a different value. Always discriminate on page.type, never fields.page_type.

Collections

TentoCMS uses kebab-case slugs for collection types (not snake_case). System fields are prefixed with an underscore to avoid collisions with your content fields.

// ButterCMS collection item
{
  meta: {
    id: 42,
    slug: 'footer-nav',
  },
  label: 'Footer',
  url: '/footer',
}

// TentoCMS collection item
{
  _sortOrder: 0,
  _publishedAt: '2026-01-15T10:00:00Z',
  _updatedAt: '2026-02-01T14:30:00Z',
  // Your content fields are merged at the root level
  name: 'Footer Navigation',
  label: 'Footer',
  url: '/footer',
}

⚠️ Collection items have only three system fields — there is no _id, _slug, or _name. An item is { _sortOrder, _publishedAt, _updatedAt } merged with your content fields at the root. Reading item._slug or item._name returns undefined. To find an item by a human-readable name, filter on a content field (below), not on a system slug.

System fields on collection items:

FieldTypeDescription
_sortOrdernumberSort position
_publishedAtstringPublication timestamp
_updatedAtstringLast update timestamp

Fetching collection items — treat the result as an unordered set. collections.list() returns items in _publishedAt descending order by default — not the authored _sortOrder order. So the primary rule is: select the item you want by a content field, never by array position. Positional access (items[0]) is the natural thing a migrating Butter app already does, and it silently returns the wrong item — a quiet content regression, not a crash:

// ✅ Do this — order-independent and intent-explicit
const header = await tento.collections.list('navigation-bar', {
  filters: [{ field: 'name', operator: 'eq', value: 'Header Navigation' }],
})
const headerNav = header.data[0] // exactly the item you asked for

// ❌ Not this — `navigation-bar` comes back as [Footer, Header], so items[0] is the FOOTER
//    (which has an empty navigation_links array) → an empty header menu, no error.
const navItems = await tento.collections.list('navigation-bar')
const wrong = navItems.data[0]

If you genuinely need authored order (e.g. positional rendering of a list), request it explicitly with _sortOrder ascending rather than re-sorting client-side:

const ordered = await tento.collections.list('navigation-bar', {
  sort: '_sortOrder',   // the system field shown on items; maps to the source order
})
// REST equivalent: GET /api/v1/collections/navigation-bar?sort=_sortOrder
// Descending: GET /api/v1/collections/navigation-bar?sort=-_sortOrder

Filtering by content fields uses filter[field][op]=value query params (or the SDK filters array). Operators: eq, ne, gt, gte, lt, lte, in, contains — e.g. ?filter[name][eq]=Header Navigation. See Getting Started and the Public API Reference for the full filter, sort (-field prefix form), and pagination syntax.

⚠️ Singleton collections return a single object, not an array. For a singleton collection, list()'s data is the item itself (T), not T[] — the type is T[] | T. Guard before iterating: const items = Array.isArray(res.data) ? res.data : [res.data].

Blog posts

Blog posts have a fixed schema in TentoCMS, not a flexible fields object. Access all blog post properties directly on the post object.

For complete blog API documentation, including:

  • Full response shapes with all fields
  • Admin and public endpoints
  • Publishing workflows
  • Category and tag management

See: Blog API Reference for the public read API, and the Blog Admin API for admin endpoints, publishing workflows, and category/tag management.

Field mapping:

ButterCMS fieldTentoCMS fieldNotes
bodycontentHTML or markdown
summaryexcerptShort description
featured_image (string URL)featuredImage (TentoMedia object)See Image handling
author.first_name + author.last_nameauthor.nameCombined into single field
author.profile_imageauthor.avatarUrlRehosted to TentoCMS media on import on a best-effort basis — usually a Tento /media/… URL. On any rehost failure (or when the author was deduplicated from an earlier post) the original cdn.buttercms.com URL is kept and a warning is recorded in the Import report. See the note below — verify after import and keep cdn.buttercms.com allow-listed until confirmed.
seo_titleseo.metaTitleNested under seo object
meta_descriptionseo.metaDescriptionNested under seo object
statusstatusSame concept
tags[].name, tags[].slugtags[].name, tags[].slugSame structure
categories[].name, categories[].slugcategory.name, category.slugSingle category, not an array

Fields with no Tento equivalent — compute or stub in your adapter:

ButterCMS fieldTento equivalentWhat to do
read_time— (absent)Compute from content word count: Math.ceil(wordCount / 200)
updated / updatedAtupdatedAtMapped directly — public blog posts expose updatedAt (use it for JSON-LD dateModified).
featured_image_alt (separate string)— (dropped on import)The migration adapter reads featured_image_alt from the source post but never writes it anywhere — it isn't propagated to the created media record's altText, or to any other field. Every migrated blog post's featuredImage._altText will be empty/null regardless of what was set in ButterCMS. If you need alt text preserved, re-populate it manually (or via a one-off script against the Media API) after import — don't rely on the importer for this field today.
author.first_name + author.last_namesingle author.nameStore the full name in first_name and set last_name to '', or split on the first space — consumers that concatenate first_name + ' ' + last_name still get the full name
categories[] (array)single categoryWrap as post.category ? [post.category] : [] wherever an array is expected

Example update for a blog post component:

// ButterCMS
function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.summary}</p>
      <img src={post.featured_image} />
      <p>By {post.author.first_name} {post.author.last_name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.body }} />
    </article>
  )
}

// TentoCMS
function BlogPost({ post }) {
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.excerpt}</p>
      <img src={post.featuredImage?._url} alt={post.featuredImage?._altText ?? ''} />
      <p>By {post.author?.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

⚠️ Security Warning: XSS Risk

The examples above use dangerouslySetInnerHTML to render HTML content directly from the CMS. This approach is vulnerable to cross-site scripting (XSS) attacks if malicious HTML or JavaScript is stored in content fields (for example, through a compromised CMS account).

Anyone with editor access who can modify post.content or other rich text fields could inject <script> tags or event handlers (onclick, onerror, etc.) that execute in end-user browsers.

Before using dangerouslySetInnerHTML in production:

  1. Sanitize HTML server-side using a trusted library like DOMPurify (Node.js/browser) or sanitize-html (Node.js):
    import DOMPurify from 'isomorphic-dompurify'
    
    function BlogPost({ post }) {
      const sanitizedContent = DOMPurify.sanitize(post.content)
      return (
        <article>
          <h1>{post.title}</h1>
          <div dangerouslySetInnerHTML={{ __html: sanitizedContent }} />
        </article>
      )
    }
    
  2. Or use a safe-by-default rich text renderer that parses and renders content without raw HTML injection:
    import Markdown from 'react-markdown'
    
    function BlogPost({ post }) {
      return (
        <article>
          <h1>{post.title}</h1>
          <Markdown>{post.content}</Markdown>
        </article>
      )
    }
    
  3. Content Security Policy (CSP): Deploy a strict CSP header (Content-Security-Policy: default-src 'self'; script-src 'self') to prevent inline script execution even if malicious content bypasses sanitization.

Do not render untrusted HTML without sanitization. Even if you trust your CMS editors today, account compromises, supply chain attacks, or future team changes introduce risk.


Component Identification

ButterCMS identifies components with a type property using snake_case. TentoCMS uses a _type property with kebab-case.

ButterCMS typeTentoCMS _type
herohero
cta_bannercta-banner
quote_sectionquote-section
text_blocktext-block

Update every switch or if/else block that inspects the component type identifier:

// Before (ButterCMS)
function renderComponent(component) {
  switch (component.type) {
    case 'hero':        return <Hero {...component.fields} />
    case 'cta_banner':  return <CtaBanner {...component.fields} />
    case 'quote_section': return <QuoteSection {...component.fields} />
    default: return null
  }
}

// After (TentoCMS)
function renderComponent(component) {
  switch (component._type) {
    case 'hero':         return <Hero {...component} />
    case 'cta-banner':   return <CtaBanner {...component} />
    case 'quote-section': return <QuoteSection {...component} />
    default:             return null
  }
}

Do not confuse component._type with page.type. The page.type property is the page type slug (e.g. landing-page), not a component identifier.

Components expose their content fields at the root — there is no .fields sub-object. Each nested/repeater component item is its content fields plus a kebab-case _type discriminant; a component's own variant field (e.g. type) sits alongside _type, so read it as component.type, not component.fields.type. That is why the renderer above spreads {...component}, not {...component.fields}. If older docs or examples show component.fields, they are wrong for the current API.


SDK Method Mapping

ButterCMSTentoCMS
butter.page.retrieve('*', 'slug')tento.pages.getBySlug('slug')
butter.page.list('page_type')tento.pages.list({ pageType: 'page-type' })
butter.content.retrieve(['collection_key'])tento.collections.list('collection-key')
butter.content.retrieve(['collection_key'], { ... })tento.collections.getBySlug('collection-key', 'item-slug')
butter.post.retrieve('slug')tento.blog.posts.getBySlug('slug')
butter.post.list()tento.blog.posts.list()
butter.category.list()tento.blog.categories.list()
butter.tag.list()tento.blog.tags.list()

Note that tento.blog.categories.list() returns BlogCategory[] directly, not a ListResponse wrapper. The same applies to tento.blog.tags.list().

⚠️ getBySlug() throws TentoNotFoundError on 404 — it does not return null. This applies to tento.pages.getBySlug(), tento.collections.getBySlug(), and tento.blog.posts.getBySlug(). ButterCMS effectively returned empty/null for a missing slug; the SDK throws instead. A direct port therefore 500s on every not-found URL if callers expect a nullable result.

If your Nuxt/Next pages do if (!data) throw createError({ statusCode: 404 }), catch the error and return null at the call site — otherwise a missing slug becomes a 500 instead of a 404:

import { TentoNotFoundError } from '@tentocms/client'

let page = null
try {
  page = await tento.pages.getBySlug(slug, { preview })
} catch (e) {
  if (!(e instanceof TentoNotFoundError)) throw e
  // 404 → null, matching ButterCMS's empty response
}

⚠️ Slugs are unique per page type — always pass pageType if slugs can collide.pages.getBySlug(slug) without a pageType option is ambiguous when the same slug exists under multiple page types (e.g. banking as both a main-page and a guides-index). The API silently returns one of them — a quiet content regression, not an error. Whenever your routing can produce such collisions, pass the type explicitly:

const page = await tento.pages.getBySlug(slug, { pageType: 'main-page' })

⚠️ collections.list() also throws TentoNotFoundError on an unknown type slug. The not-found guidance above focuses on getBySlug, but collections.list('unknown-type') throws the same error on a 404. This matters most for navigation fetches that run on every page render — an unhandled throw there degrades every page. Wrap those calls in the same try/catch:

import { TentoNotFoundError } from '@tentocms/client'

let nav = null
try {
  nav = await tento.collections.list('navigation-bar')
} catch (e) {
  if (!(e instanceof TentoNotFoundError)) throw e
  // collection type not found — safe fallback
}

Listing by page typetento.pages.list({ pageType: 'page-type' }) filters server-side by the page-type slug and returns a correctly paginated result, so you do not need to over-fetch and filter in application code. Each page in the list response includes its full fields and seo — not just metadata — so a single list call per page type is enough to build a sitemap or index without N follow-up getBySlug() calls (which also avoids tripping the WAF burst rule documented later in this guide). References in pages.list() responses are already resolvedfields contains the full resolved object (e.g. fields.category.name), not raw _ref tokens. This is what makes the reference-sub-field-filtering workaround described below possible: you can read and filter on fields.category.name client-side because the list response has already resolved the reference. List responses include a pagination object ({ total, page, limit, totalPages }), and limit maxes out at 100 (limit > 100 returns 400 INVALID_PAGINATION) — page through large types with page/limit rather than requesting everything at once.

⚠️ Default page ordering is publishedAt descending — non-deterministic for bulk-imported content. pages.list() defaults to publishedAt descending. When content is bulk-imported, timestamps are often near-identical, making the order effectively arbitrary. Rendering "the first N pages of a type" then picks an unstable subset that may differ from the source CMS order and vary between requests. For any "top N" or ordered display, pass an explicit, stable sort field — slug or name order page lists reliably (prefix form: name = ascending, -name = descending). (_sortOrder is collections-only — not a valid page sort field.) This applies equally to fixed-count embedded sections (e.g. a "render the first 6 guides" grid inside a page): bulk-imported content with near-identical timestamps makes the set shown vary run-to-run, not just the order. BINQ fixed this by passing sort: 'slug' on the embedded list call. Apply the same stable-sort fix wherever you take a count-limited slice of a page list, not only on paginated index pages.

// Stable alphabetical order — deterministic even with identical timestamps.
// All resources use the same prefix sort form: 'name' = ascending, '-name' = descending.
const pages = await tento.pages.list({ pageType: 'case-study', sort: 'name' })
// REST: GET /api/v1/pages?pageType=case-study&sort=name   (descending: sort=-name)

💡 Filtering pages by content fields (server-side). Beyond the page-type filter, pages.list() supports content-field filters — the same json_extract engine as collections. Pass filter[fields.<path>][<op>]=<value> query params (or the SDK filters array), e.g. filter[fields.tier][eq]=pro (nested scalar paths like fields.hero.headline work too — but not resolved-reference sub-fields; see the caveat below). Operators: eq, ne, gt, gte, lt, lte, in, contains. Scope by type with ?pageType=<slug> and layer content filters on top — no need to fetch every page and filter in application code. The returned pagination.total reflects the filtered set. (Collections filter the same way via filters: [{ field, operator, value }].)

Caveat: server-side filters run on the raw stored content, not resolved references. The json_extract filter reads the value as it exists in the database — an unresolved _ref string — not the object that the response builder resolves it into. This means scalar content fields (strings, numbers, booleans) filter correctly, but you cannot filter on a resolved-reference sub-field. For example, if category is a reference field that resolves to { name, slug, _type } in the response, a filter on fields.category.name matches against the raw _ref token, not the resolved name — it will silently return no results. For those cases, fetch the page type without a content filter and apply the filter client-side on the adapted shape. When filtering client-side, be aware of the list page-size cap. A single list() call returns at most 100 items (limit: 100). If a page type exceeds 100 published pages you must paginate. Pagination is page-number based: the response's pagination object has { total, page, limit, totalPages } (there is no cursor). Loop by incrementing the page query param while page < totalPages — rather than assuming one request returns the full set.

filter[slug][eq]=<slug> on the pages list now works. Filtering the public page list by slug (GET /api/v1/pages?filter[slug][eq]=about-us) correctly returns only the matching page(s). getBySlug(slug, { pageType }) remains the idiomatic single-page fetch (it returns one page directly and throws TentoNotFoundError on miss), but a slug filter on the list endpoint is now functional if you need the list wrapper, pagination metadata, or to combine slug with other filters.


Image Handling

ButterCMS returns images as plain URL strings. TentoCMS returns images as TentoMedia objects with underscore-prefixed fields.

interface TentoMedia {
  _url: string
  _mimeType: string
  _width?: number
  _height?: number
  _altText?: string
}

Update image references in templates:

// ButterCMS
<img src={post.featured_image} />

// TentoCMS
<img
  src={post.featuredImage?._url}
  alt={post.featuredImage?._altText ?? ''}
  width={post.featuredImage?._width}
  height={post.featuredImage?._height}
/>

Image transformation API

TentoCMS applies image transforms via query parameters on the media URL. The SDK's tento.media.* helpers build those URLs for you when you hold a media id:

// Simple transformed URL (requires a media id)
const url = tento.media.getImageUrl('media-id', {
  width: 800,
  height: 600,
  fit: 'cover',
  quality: 85,
  format: 'webp',
})

// Convenience thumbnail (400x400, cover, quality 80)
const thumb = tento.media.getThumbnailUrl('media-id')

// Responsive srcset
const srcset = tento.media.getSrcSet('media-id', [400, 800, 1200])

getImageUrl(), getThumbnailUrl(), and getSrcSet() are synchronous — they build a URL string and make no HTTP request.

⚠️ Inline TentoMedia has no id, so getImageUrl(id, …) cannot be used on images already resolved in page/collection content. A resolved TentoMedia object exposes only _url, _mimeType, _width, _height, _altText — there is no id/_id. For inline media, append transform params to _url directly:

const src = `${media._url}?width=800&fit=cover&format=webp`

Use the getImageUrl(id, …) helpers only where you actually hold a media id (e.g. from the media API), not for media embedded in content. (The SDK's media.transformUrl(url, opts) appends transform params to a _url string for you.)

✔ Optional media is TentoMedia | undefined — never "". As of the current API version, an unset MEDIA or IMAGE field returns null from the API, which the SDK's stripNulls converts to undefined. You will never receive "" for an unset media field on a current deployment.

A set image is a TentoMedia object; an unset one is undefined. Passing the object straight to <img :src> still renders [object Object], so with the boundary-adapter strategy, collapse any _url-bearing object to its string once at the boundary rather than ?._url at every call site:

const flattenMedia = (v: any): any =>
  Array.isArray(v) ? v.map(flattenMedia)
    : v && typeof v === 'object'
      ? ('_url' in v ? v._url : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, flattenMedia(x)])))
      : v

If your adapter already treats "" as a no-op (i.e. the pass-through branch in flattenMedia above handles it as a plain value), that is harmless — but on a current deployment "" will never arrive for a media field, so any special-casing for it is dead code. Keep it only if the app must also run against an older API deployment that predates this normalisation.

⚠️ flattenMedia discards _altText. Collapsing a TentoMedia object to its _url string drops every other field, including _altText. Where a separate alt field is expected — for example, blog featured_image_alt — capture _altText before flattening: const alt = post.featuredImage?._altText ?? '' then call flattenMedia. This pattern is correct in general, but be aware that for imported blog posts specifically, _altText will be empty regardless — see the featured_image_alt note in Blog posts above; the migration adapter never propagates the source alt text.

💡 Strip all _-prefixed fields when building your clean Butter shape. The example cleaners above only strip _type. Collection items also carry _sortOrder, _publishedAt, and _updatedAt at the root, which will leak into the adapted shape if not removed. Tento reserves the _ prefix for system fields, so dropping every key that starts with _ is safe and gives you a predictable, clean output:

const stripSystemFields = (obj: Record<string, unknown>) =>
  Object.fromEntries(Object.entries(obj).filter(([k]) => !k.startsWith('_')))

Note: transforms have two states — disabled or working. Transform params are always accepted (the request succeeds). If Image Resizing is not active on the deployment, the API returns the original image unchanged — the params become a graceful no-op. If width/height/quality appear to have no effect, the resizing layer is likely disabled on that domain rather than the params being wrong.

The media route now defaults transforms to webp (paired with the quality=85 default), so the old lossless-VP8L re-encoding path that produced files larger than the source is gone. For clarity and forward compatibility, always append format=auto or format=webp explicitlyformat=auto is mapped to webp and is the recommended form. BINQ measured a 289 KB source image coming back as 52 KB at ?width=640&height=256&fit=cover&format=auto. Still measure byte sizes on your own assets; gains vary with content.

@nuxt/image and next/image consumers: use the bare _url, not a pre-built transform URL. These components strip the query string and re-derive their own transforms from the component's width/height/quality props — any params you append to _url are dropped before the request is made. Pass _url directly as the src and let the image component manage transforms. (For remote images without intrinsic dimensions, NuxtImg may emit a degenerate s_1x1 placeholder src while the real srcset entries resolve fine — this is expected behaviour, not an error.)

Porting an existing image-optimisation helper? If your app had a Butter-specific URL rewriter (e.g. a Filestack-style resize=width:…,height:… keyed to cdn.buttercms.com), it silently no-ops on TentoCMS URLs — different host and different transform syntax. Images still render, but optimisation is lost. Re-point the helper to append TentoCMS params (?width=&height=&fit=&format=) to _url.

Detect TentoCMS media by path, not by host. Keying your rewriter off the hostname (e.g. tento-api.intelligentlending.co.uk) is brittle: the host differs across environments and may be proxied or aliased locally. Instead, detect TentoCMS media URLs by the path segment /api/v1/media/ — present in every media URL regardless of host — and pass all other URLs through unchanged:

function appendTentoTransforms(url: string, opts: { width?: number; format?: string } = {}): string {
  if (!url.includes('/api/v1/media/')) return url  // not a Tento media URL — leave it alone
  // Build the query string without `new URL()` — that throws on RELATIVE URLs
  // (e.g. "/api/v1/media/..."), which the API can emit. String-append instead so
  // both absolute and relative media URLs work.
  const params = new URLSearchParams()
  if (opts.width)  params.set('width', String(opts.width))
  if (opts.format) params.set('format', opts.format)
  const qs = params.toString()
  return qs ? url + (url.includes('?') ? '&' : '?') + qs : url
}

If your boundary-adapter recipe has a media helper, apply the same path-based guard there.

Allow-list the TentoCMS media host. Media moved off cdn.buttercms.com to the TentoCMS API host (e.g. tento-api.intelligentlending.co.uk). Frameworks that proxy/optimise images must add it — @nuxt/image image.domains, Next.js images.remotePatterns, etc. — otherwise the image component rejects the URL.

For complete documentation on image transformations, see:

Supported transform parameters:

ParameterValuesDefault
width1–4000original
height1–4000original
fitscale-down, contain, cover, crop (no pad mode)scale-down
quality1–10085
formatwebp, avif, auto, json (no jpeg/png values)webp — supplying any other transform param without an explicit format still re-encodes to webp, it does not pass through the original format
gravityauto, center, top, bottom, left, rightcenter
dpr1–31

Total pixels after DPR scaling (width × height × dpr²) also can't exceed 25 megapixels — a validation error rejects requests beyond that, independent of the individual width/height caps above.


Common Gotchas

null vs undefined

The client SDK automatically converts all null values to undefined via the stripNulls() utility function called in makeRequest() and makeWriteRequest(). This ensures that optional fields in responses (such as TentoMedia._altText, _width, _height) are typed and behave as string | undefined or number | undefined, matching framework expectations for component props.

⚠️ Optional fields are missing or undefined, never null. The SDK's stripNulls turns any explicit null into undefined (the key stays, valued undefined), and fields the API or migration omits aren't present at all — e.g. a custom meta SEO container removed on import (see the SEO warning above). Either way, reach through with optional chaining (data.fields.meta?.meta_canonical); never test === null.

⚠️ A Nitro/JSON proxy route drops undefined-valued keys entirely from the serialised response. stripNulls converts null to undefined, but JSON.stringify omits keys whose value is undefined — so when a TentoCMS response passes through a Nuxt server route or any other JSON proxy, an unset field such as hero_section.image arrives at the browser absent from the object entirely (not as null, not as undefined, not as ""). Consumers using x.image || fallback or x.image ?? fallback are fine — both handle an absent key the same as undefined. Consumers that check key presence (e.g. 'image' in x, Object.hasOwn(x, 'image')) will not find it and should treat absence as unset.

You can pass optional fields directly to component props without any conversion:

// Correct - SDK already returns undefined for absent fields
<img
  src={post.featuredImage?._url}
  alt={post.featuredImage?._altText}
  width={post.featuredImage?._width}
  height={post.featuredImage?._height}
/>

The SDK handles this sanitization transparently, so you don't need ?? undefined coalescing patterns.

Collection key format

Kebab-case is the canonical form for collection slugs in TentoCMS. GET /api/v1/schemas and @tentocms/typegen both emit kebab-case slugs, and kebab-case is what you should use in new code. That said, the public collection list endpoint intentionally normalizes underscores to hyphens — c.req.param('type').replace(/_/g, '-') — so GET /collections/guide_category and GET /collections/guide-category return identical data. A snake_case slug resolving successfully is not a bug to chase down; it is expected behaviour. Use kebab-case as canonical; just don't be alarmed if a legacy snake_case call unexpectedly succeeds.

// ButterCMS (snake_case)
butter.content.retrieve(['navigation_bar'])

// TentoCMS (kebab-case — canonical form)
tento.collections.list('navigation-bar')

Grep your codebase for every content.retrieve call and update each key to kebab-case.

⚠️ GET /api/v1/schemas reports reference options in snake_case — but the list endpoint wants kebab-case. When you inspect a component schema via GET /api/v1/schemas, reference field options list their collectionType in snake_case (e.g. "guide_category", "background_colours", "success_story", "authors"). These are not the slugs you pass to collections.list() or GET /api/v1/collections/:slug. The public collection endpoints expect the kebab-case slug (guide-category, background-colours, success-story, authors). Convert snake_case → kebab-case (collectionType.replaceAll('_', '-')) whenever you programmatically turn a schema reference option into a collection list call. (The underscore-normalization above means both forms resolve, but kebab-case is the canonical slug reported by the schema and generated by typegen.)

Partial schema migration

If not all component schemas were created in TentoCMS before starting the application migration, you will encounter components that still use the ButterCMS { type, fields } format alongside components in the TentoCMS format. This requires a hybrid rendering approach, for example:

function getComponentType(component) {
  // TentoCMS components use _type (kebab-case)
  if ('_type' in component) return component._type
  // Legacy ButterCMS components (snake_case to kebab-case)
  return component.type.replaceAll('_', '-')
}

This adds ongoing maintenance burden. Verify all component schemas exist in TentoCMS before beginning application changes.

Generating types with @tentocms/typegen

If you use TypeScript, generate types from your live schema rather than hand-writing them:

npm install -D @tentocms/typegen
npx tentocms-typegen init      # creates tentocms.config.js

tentocms-typegen init writes an ESM config (export default), which is correct for any "type": "module" project (Nuxt 4, etc.). A module.exports form will fail to load there — if you must use CommonJS, name the file tentocms.config.cjs. init also detects your project layout and writes a sensible output: ./src/types/cms.ts when a src/ directory exists, otherwise ./types/cms.ts (correct for Nuxt 4, which has no src/). Adjust it if your layout differs.

// tentocms.config.js — reuse the same env vars as the SDK setup above
export default {
  apiKey: process.env.TENTO_API_KEY,
  apiUrl: process.env.TENTO_BASE_URL, // https://tento-api.intelligentlending.co.uk
  output: './types/cms.ts',          // init writes ./src/types/cms.ts when a src/ dir exists
}
# generate auto-loads ./.env when present — or point it at any file with -e/--env:
tentocms-typegen generate                # auto-loads ./.env if it exists
tentocms-typegen generate -e .env.local  # explicit env file

Real environment variables always take precedence over the .env file (it never overrides them), so this is safe in CI. The older node --env-file=.env node_modules/.bin/tentocms-typegen generate form still works. (Use -e/--env, not a bare --env-file: Node ≥22 reserves --env-file for its own loader and swallows it before the CLI sees it.)

Add a types:generate npm script for this and wire it into prebuild so types stay in sync with the schema. Avoid postinstall — it runs on every npm install (CI, fresh clones), where TENTO_API_KEY is absent, and would break the install.

Make the prebuild step tolerant so a CI or deploy environment without TENTO_API_KEY falls back to the committed types/cms.ts rather than failing the build — while still letting real typegen failures fail the build when the key is present:

{
  "scripts": {
    "types:generate": "if [ -n \"$TENTO_API_KEY\" ]; then tentocms-typegen generate; else echo 'TENTO_API_KEY absent — skipping typegen, using committed types'; fi",
    "prebuild": "npm run types:generate"
  }
}

Gate the skip on the key, not on the exit code: a missing key → skip regeneration and use the committed types/cms.ts; key present but typegen fails (a real regression, or a schema/API/config error) → the build fails. Avoid the blanket tentocms-typegen generate || echo … form — it swallows all failures, silently shipping stale committed types when something is genuinely broken. Regeneration still runs locally and in any pipeline that has the key, but a deploy without it stays green.

The framework guides cover end-to-end setup: Nuxt, Next.js, Astro.

*Content vs *Fields. For each page type the generator emits two interfaces: <Name>Content (includes the base id/slug/title/seo) and <Name>Fields (your custom fields only). Use *Fields to type the runtime page.fields object — page.fields does not contain the system id/slug/title/seo (those are top-level and under page.seo), so *Content won't match it directly. Exception: if a page type declares its own content field named title, slug, id, or seo, that field does appear in page.fields at runtime — and *Fields includes it accordingly. This is rare but intentional: *Fields excludes the system base fields, not any same-named content field the page type itself declares.

Note for boundary-adapter apps. @tentocms/typegen output (*Content/*Fields, AnyComponent[], ResolvedReference) describes the raw TentoCMS response shape — not the adapted object your components receive after translation. If you translate TentoCMS responses back into your old CMS shape at the data boundary, the generated types are reference material only; keep your existing hand-written component prop types for the adapted shape.

Typegen limitations

@tentocms/typegen has a few known limitations:

  • Repeater/list fields are typed as Record<string, unknown>[] when the source schema declares no sub-fields for them (repeaters with declared sub-fields get a precise item type). Declare the sub-fields in the source schema for a typed array, or cast at the consumption point.
  • Reference fields can be typed differently across components when the source schema models the same field differently — e.g. a background-colours reference typed as a reference in one component (→ ResolvedReference & …) but as a plain object in another (→ Record<string, unknown>). Align the field's definition across components for consistent output, or create a manual intersection type, for example:
    import type { BackgroundColoursItem } from './cms'
    type BackgroundRef = { _id: string; _slug: string } & BackgroundColoursItem
    
  • json fields are typed unknown, not Record<string, unknown>, because a json field may hold an array or an object. You must narrow or cast at the use site before accessing properties:
    const stats = fields.stats as Array<{ metric: string; value: number }>
    
  • Duplicate collection/component slugs are de-duplicated by keeping the definition with the most fields (a warning is printed). If two definitions genuinely differ, fix the source schema, since the smaller one is dropped.
  • A stale meta field can appear on collection interfaces if your content was imported by an older migration (before the adapter stripped ButterCMS's system meta). The live API never returns meta on collection items — re-run the migration, or remove the meta field from the collection type, to clear it.

page.type vs component._type

These are different things:

  • page.type — the slug of the page type this page belongs to (e.g. landing-page)
  • component._type — the slug of a component within a page's content (e.g. cta-banner)

Do not use page.type to identify components.

Blog posts use .content not .fields

Blog posts do not have a .fields object. All blog post properties are top-level on the post object. The full body content is under .content (not .body as in ButterCMS, and not .fields.body).

// Wrong
post.fields.body
post.body

// Correct
post.content

Per-request preview toggle

ButterCMS requires a separate client instance configured with a preview token. TentoCMS toggles preview per request on the same client. Preview is opt-in: content is live unless you explicitly pass { preview: true }. Omitting the flag (or passing { preview: false }) always returns published content — even when a previewKey is configured — so you can share one client between live and preview rendering without ever serving drafts by accident.

const tento = new TentoClient({
  apiKey: process.env.TENTO_API_KEY!,
  baseUrl: process.env.TENTO_BASE_URL!,
  previewKey: process.env.TENTO_PREVIEW_KEY,
})

// Live content (default — no flag needed, even with a previewKey set)
const page = await tento.pages.getBySlug('home')

// Draft/preview content — opt in explicitly
const draftPage = await tento.pages.getBySlug('home', { preview: true })

// Explicitly live (same as omitting the flag)
const livePage = await tento.pages.getBySlug('home', { preview: false })

This removes the need to maintain separate client instances for preview and live rendering.

💡 preview: true falls back to published content when no draft exists. When you request a page with { preview: true } and that page has no unpublished draft, TentoCMS returns the published content — it does not 404 or return empty. This is intentional: forcing preview locally (e.g. via a middleware that always sets preview: true) means published-only pages still render correctly. You will not see a blank page or a TentoNotFoundError simply because content has no draft. The returned content is the current published version until a draft is created.

Publish status of imported content

The migration wizard preserves each source page's publish state: a ButterCMS page that was published imports as a published TentoCMS page; an unpublished one imports as a draft. Collection items always import as published (ButterCMS collections have no draft workflow). This trips teams up in two ways:

  • The public API serves published content only. GET /api/v1/pages and the SDK's pages.list() — including the ?pageType= filter — omit drafts unless you pass { preview: true } with a configured preview key. So a "verify by listing pages" step silently under-reports: a page type whose pages all imported as drafts returns zero results until you preview. (This is also why GET /api/v1/schemas is the reliable way to enumerate page-type slugs — it lists every type regardless of publish state.)
  • Draft pages are treated as not-found in production. getBySlug() throws TentoNotFoundError (it does not return null) when the requested slug resolves to a draft and no preview key is active — it only renders locally if your dev setup forces preview on. Catch the error as described in the SDK Method Mapping gotcha.

To publish imported drafts in bulk, use the admin write API with an authenticated session (publisher role or higher) — there is no public or SDK publish method:

EndpointBodyResult
POST /api/v1/admin/pages/bulk-publish{ "ids": ["<pageId>", …] } (1–100){ published, failed }; fires page.published webhooks
POST /api/v1/admin/collection-items/bulk-publish{ "ids": ["<itemId>", …] } (1–100)publishes the listed items

You can also publish pages individually from the TentoCMS admin UI. Either way, after import confirm the pages you expect to be live are published (or deliberately left as drafts) before pointing traffic at them.

Reference Resolution

TentoCMS automatically resolves _ref objects in your content. When a page or collection item contains references to other content (users, pages, blog posts, media), they are automatically resolved and replaced with the referenced data. Note that different reference types return different levels of detail: collection items return full content, while page and blog post references return metadata only.

_type is on every nested object — including repeater/array items. It isn't only top-level components — every resolved nested group, reference object, and repeater/array item carries its own kebab-case _type discriminant. Examples: background{ colour, name, _type: "background-colours" }, a CTA URL → { url, _type: "urls" }, products_list[] items → { …, _type: "products" }, navigation_links[] items → { …, _type: "navigation-item" }, children[] items → { …, _type: "navigation-child-item" }. Reading named fields is unaffected, but code that deep-compares or spreads nested items onto props should expect the extra _type key everywhere.

✔ Empty references now have consistent, predictable shapes. As of the current API version, unset reference fields are normalised by their declared schema type:

  • Unset single reference field → null from the API → undefined via the SDK (the SDK's stripNulls converts null to undefined). You will never see {} for an unset single reference on a current deployment.
  • Unset multiple/array reference field → [] (always an empty array). You will never see {} for an unset array reference on a current deployment.

This applies to reference fields at the top level of a page or collection item, inside components (nestedComponent/componentPicker), and inside repeater items.

If you added guards for {} (e.g. background && Object.keys(background).length > 0), they are harmless and do not need to be removed — but they are no longer necessary against a current deployment. Keep them if your app must also run against an older API deployment that predates this normalisation.

📝 Empty json-typed fields also come back as {}, not null or absent. An unset json field (e.g. an unset location or a card-level background) resolves to {}. Reading .url or .colour off it yields undefined (safe), but guard accordingly — the field is present as {}, not missing.

⚠️ Collection-backed author reference: the image field is image, not profile. When a guide or page has an author field that references the authors collection, the resolved object is { name, title, image: TentoMedia, _type: 'authors' }. ButterCMS exposed the same author image under profile — so code reading author.profile silently gets undefined after migration. Rename all reads of author.profile to author.image (a TentoMedia object, not a URL string). Note this is distinct from blog post authors, where the image is author.avatarUrl — a plain URL string that is best-effort rehosted to a Tento-hosted /media/… URL at import time.

⚠️ Blog author avatar rehosting can silently fall back to the source URL — keep cdn.buttercms.com allow-listed until you've verified. Rehosting is attempted per author, but on any failure (network/CDN error fetching the source image, R2 error) — or when the same author is reused across posts and was created from an earlier one — the importer keeps the original cdn.buttercms.com URL and records a warning in the Import report rather than failing the migration. So author.avatarUrl may come back as either a /media/… URL or a cdn.buttercms.com URL. Unlike guide/page collection authors (whose image field is reliably Tento-hosted), do not assume blog author avatars are rehosted:

  1. After import, check the Import report for avatar rehost warnings, and spot-check a few author.avatarUrl values.
  2. Keep cdn.buttercms.com in your @nuxt/image image.domains / images.remotePatterns allow-list until every author avatar is confirmed Tento-hosted — otherwise avatars that fell back to the source URL fail to load. (This is exactly the safety net a real migration relied on.)

🔴 Any unset reference or nested-component field that ButterCMS returned as an empty-string object now arrives as null/undefined in TentoCMS. This is the same class of problem as the fields.meta container described in Data Shape Differences: ButterCMS sent defined fields as "" (never null), so an unset author reference came back as { name: '', title: '', profile: '' } — something truthy that deep reads could traverse safely. TentoCMS returns null for unset references, which the SDK's stripNulls converts to undefined. An unguarded deep read like guidePage.fields.author.name (or location.url, seo.canonicalUrl) therefore throws at SSR and 500s the page — even if it never crashed under ButterCMS.

In a boundary adapter, coerce unset references to non-null objects with empty-string defaults — exactly as advised for fields.meta — so existing consumer code that accesses sub-fields without optional chaining stays safe:

// In your adapter, after resolving the page:
const author = resolvedPage.fields.author ?? { name: '', title: '', profile: '' }

Use optional chaining (page.fields.author?.name) at every call site where you read direct TentoCMS responses rather than an adapted shape. Any field that was a non-null object in ButterCMS but is a reference or nested component in TentoCMS is a candidate for this treatment — audit every deep read in your templates before migrating.

For complete documentation on reference resolution behavior, including:

  • All reference types (_ref, _media, typed references)
  • What fields are resolved for each type
  • Nested reference handling
  • Performance optimizations (batch loading)
  • Depth limits and error handling

See: Getting Started and Field Types for reference-resolution behaviour and what each reference type resolves to.

Slug redirects (SEO)

When a page's slug changes, the old slug keeps working. The raw REST endpoint wraps the page: GET /api/v1/pages/:slug returns { data, redirect } — your page is under data, and redirect is { from, to, permanent } | null (populated when you fetched via an old slug, so you can issue a 301). Collection items behave similarly. The SDK's getBySlug() unwraps to .data and drops redirect — so anyone hand-rolling REST to honour redirects must read .data for the page and the top-level redirect for the 301.

Blog single-post REST also wraps in { data }. GET /api/v1/blog/posts/:slug returns { data: BlogPost } — the post is under .data. There is no redirect on blog posts. The SDK's blog.posts.getBySlug() unwraps this for you; if you hand-roll the REST call, read .data to get the post object.

Rate limits and the WAF

Public reads are rate-limited per API key in the Worker (counted per cache miss), with a coarser per-IP Cloudflare WAF ceiling as an anti-DDoS backstop above it. Current figures: Limits & Errors. The read limit is set generously so a full build can fetch all published content in one pass. One consequence for migrations:

  • Build-time bulk fetches (SSG looping over many pages — exactly what this guide's list loops do) can exceed the per-key limit on a large catalogue or a highly concurrent build. Prefer the paginated list endpoints (limit up to 100) over one request per item, cap your fetch concurrency, and on a 429 RATE_LIMITED add backoff and honour Retry-After. See Static-site generation & bulk reads.

Blog filtering: slugs vs IDs

The public blog list filters by category/tag IDs?categoryId=<uuid> / ?tagId=<uuid> (SDK: list({ categoryId, tagId })). These are validated as UUIDs, so passing a slug (e.g. ?categoryId=my-category) fails validation. If you only have a slug (from a URL), resolve it to its id first via the categories/tags list endpoints — each item returns both id and slug.

Blog list omits content by default — the list endpoint returns lighter BlogPostListItem objects without the post body. Opt in when you need the full HTML:

  • REST: GET /api/v1/blog/posts?includeContent=true
  • SDK: tento.blog.posts.list({ includeContent: true })

The single-post endpoint (GET /api/v1/blog/posts/:slug / blog.posts.getBySlug()) always returns content. The SDK types reflect this: list items are BlogPostListItem (content optional / absent by default) while getBySlug() returns BlogPost (content always present).

⚠️ Computing read_time on list items requires includeContent: true. The field-mapping table above advises computing read_time from the post's content word count (Math.ceil(wordCount / 200)), but content is absent on list items by default. To reproduce ButterCMS's behaviour — where the list response included the full post body and allowed read_time to be shown on post cards — pass includeContent: true to blog.posts.list():

const posts = await tento.blog.posts.list({ includeContent: true })
const withReadTime = posts.data.map(post => ({
  ...post,
  read_time: Math.ceil((post.content?.split(/\s+/).length ?? 0) / 200),
}))

Without includeContent: true, list items have no content and a computed read_time is always 0 or undefined. The single-post endpoint always includes content, so this only affects post-card or index-page rendering that uses the list call.

Rich text (wysiwyg) is NOT sanitized server-side — migrated content bypasses sanitization entirely

Unlike what you might expect, wysiwyg content is not sanitized on the server at any point. The DOMPurify allowlist (which does strip dangerous tags and adds rel="noopener noreferrer" — note noreferrer, not just noopener — to external links) only runs client-side, inside the TentoCMS admin's rich-text editor component, when a human edits that field through the admin UI. It's deliberately client-only because the underlying library crashes if it runs during server-side rendering on Cloudflare Workers.

This matters directly for migration: content imported by the migration wizard is written straight to the database and never passes through the admin editor, so it never goes through this (or any) sanitizer. The same is true of any content written via direct API calls. A ButterCMS body field containing unescaped <script> tags or event-handler attributes will be imported and served back byte-for-byte as-is by the public API.

Practical implication: the XSS guidance above — sanitizing before dangerouslySetInnerHTML (or your framework's equivalent) — is not just defence-in-depth for migrated content, it is the only sanitization migrated wysiwyg/blog content ever receives. Do not skip it on the assumption that TentoCMS has already cleaned the HTML server-side; treat every content/body/wysiwyg field as untrusted at render time, regardless of how it got into TentoCMS.

Keeping content fresh (webhooks)

To replace Butter's publish→rebuild flow, subscribe to a page.published webhook (HMAC-signed via X-Webhook-Signature) and trigger a revalidate/rebuild. Nothing polls for you, so wire this up if edits need to propagate to the frontend automatically.

💡 Bulk import stamps every page's updatedAt with the import timestamp. If you use page.updatedAt as the lastmod value in your sitemap, all imported pages will share the same date — the moment the import ran — rather than their original edit dates. This is expected behaviour, not a bug. Once editors save or re-publish content after the migration, updatedAt reflects real edit times again.


Pre-Migration Checklist

Work through this list before changing any application code.

  • All component schemas created in TentoCMS
  • Content imported via migration wizard (dry run first, then full migration)
  • @tentocms/client installed (npm install @tentocms/client)
  • Generated types via @tentocms/typegen (if using TypeScript)
  • API key stored in environment variables (TENTO_API_KEY)
  • API base URL set on the client (TENTO_BASE_URL = https://tento-api.intelligentlending.co.uk)
  • Preview key configured if using preview mode (TENTO_PREVIEW_KEY)
  • Updated component renderer to use _type (kebab-case)
  • Updated image references from URL strings to TentoMedia objects
  • Updated blog post field names (body to content, summary to excerpt, featured_image to featuredImage)
  • Updated collection keys from snake_case to kebab-case
  • Updated SEO field access from page.fields.seo_title to page.seo.metaTitle (all seven seo.* fields)
  • Verified final page-type slugs after import — check the Import report for slug mappings and typeless-page counts; enumerate live types via GET /api/v1/schemas (typeless ButterCMS pages land in the legacy-pages catch-all, not a renamed type)
  • Confirmed imported pages are published (drafts are hidden from the public API — bulk-publish or publish them in admin)
  • Updated author access from post.author.first_name to post.author?.name
  • Replaced butter.category.list() / butter.tag.list() with tento.blog.categories.list() / tento.blog.tags.list()
  • Tested all pages render correctly
  • Verified preview mode works

Appendix: boundary-adapter recipe

If you centralise CMS access in a server route or utility layer, the transforms below cover every shape difference documented in this guide. Copy them into your adapter module and compose them in sequence. See the relevant sections above for the reasoning behind each transform.

import { TentoNotFoundError } from '@tentocms/client'
import type { Page, TentoMedia } from '@tentocms/client'

// ─── 1. Recursive media flatten ────────────────────────────────────────────
// Collapses any { _url, … } object to its _url string.
// Leaves undefined (and "" if present) as-is. Does NOT touch nested objects that lack _url.
// NOTE: as of the current API, unset MEDIA/IMAGE fields arrive as undefined (null → undefined
// via stripNulls), never as "". The "" pass-through below is harmless and is kept for
// compatibility with older API deployments that predate this normalisation.
// ⚠️ Capture _altText BEFORE calling this wherever a separate alt field is needed
//    (e.g. blog featured_image_alt). Example:
//      const featuredImageAlt = post.featuredImage?._altText ?? ''
//      const flat = flattenMedia(post)
export function flattenMedia(v: unknown): unknown {
  if (Array.isArray(v)) return v.map(flattenMedia)
  if (v && typeof v === 'object') {
    if ('_url' in (v as object)) return (v as TentoMedia)._url
    return Object.fromEntries(
      Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, flattenMedia(x)])
    )
  }
  return v
}

// ─── 2. Strip all _-prefixed system fields ──────────────────────────────────
// Drops _type, _sortOrder, _publishedAt, _updatedAt, etc. from the top level
// of an object. Tento reserves the _ prefix for system fields; stripping them
// gives a clean shape that matches the old { type, fields } structure.
// Apply after step 3 (so _type is still readable there) and after step 4.
export function stripSystemFields<T extends Record<string, unknown>>(obj: T): Partial<T> {
  return Object.fromEntries(
    Object.entries(obj).filter(([k]) => !k.startsWith('_'))
  ) as Partial<T>
}

// ─── 3. Component _type → type rewrap ──────────────────────────────────────
// Converts a TentoCMS component back to Butter's { type, fields } shape.
// Moves non-_type fields into fields FIRST so a component's own variant field
// (e.g. logo_marquee's `type`) lands at fields.type — not clobbered by step 2.
export function rewrapComponent(component: Record<string, unknown>): {
  type: string
  fields: Record<string, unknown>
} {
  const { _type, ...rest } = component
  const fields = stripSystemFields(rest as Record<string, unknown>)
  const type = String(_type ?? '').replaceAll('-', '_') // kebab → snake
  return { type, fields: flattenMedia(fields) as Record<string, unknown> }
}

// ─── 4. seo → fields.meta synthesis ────────────────────────────────────────
// Rebuilds the old fields.meta shape from page.seo.
// Always returns a non-null object with '' defaults (never undefined/null)
// so consumers reading fields.meta.meta_canonical without optional chaining
// don't SSR-500 on pages where the container was dropped on import.
// Re-derives page_path from seo.canonicalUrl (see Data Shape Differences).
export function synthesiseMeta(page: Page): Record<string, string> {
  const seo = page.seo ?? {}
  return {
    meta_title:       seo.metaTitle       ?? '',
    meta_description: seo.metaDescription ?? '',
    meta_canonical:   seo.canonicalUrl    ?? '',
    og_title:         seo.ogTitle         ?? '',
    og_description:   seo.ogDescription   ?? '',
    // Re-derive page_path from canonicalUrl — fields.meta?.page_path only
    // survives import when page_path is a real (defined) field in your Butter
    // meta schema; if it was a redundant app-side type, fields.meta is null.
    page_path: seo.canonicalUrl ?? (page.fields?.meta as Record<string, string>)?.page_path ?? '',
  }
}

// ─── 5. Author image remap ──────────────────────────────────────────────────
// Collection-backed author resolves the image under `image` (TentoMedia).
// ButterCMS exposed it under `profile`. Alias it so existing reads of
// author.profile keep working. (Blog post authors use avatarUrl — a plain URL
// string rehosted to TentoCMS media at import time, not a cdn.buttercms.com URL.)
export function remapAuthor(
  author: Record<string, unknown> | undefined
): Record<string, unknown> | undefined {
  if (!author) return undefined
  return { ...author, profile: author.image }
}

// ─── 6. getBySlug / list try-catch wrappers ─────────────────────────────────
// Catch TentoNotFoundError and return null / [] so existing `if (!data) throw 404`
// guards produce 404s, not 500s. Apply to every SDK call at the boundary.
export async function safeGetBySlug<T>(
  fn: () => Promise<T>
): Promise<T | null> {
  try { return await fn() }
  catch (e) {
    if (e instanceof TentoNotFoundError) return null
    throw e
  }
}

export async function safeList<T>(
  fn: () => Promise<{ data: T[] }>
): Promise<T[]> {
  try { return (await fn()).data }
  catch (e) {
    if (e instanceof TentoNotFoundError) return []
    throw e
  }
}

Compose in your adapter:

// Example: adapt a page at the data-access boundary
export async function getPage(slug: string, preview = false) {
  const page = await safeGetBySlug(() =>
    tento.pages.getBySlug(slug, { preview })
  )
  if (!page) return null

  const components = (page.fields?.components as Record<string, unknown>[] ?? [])
    .map(rewrapComponent)

  return {
    slug: page.slug,
    type: page.type?.replaceAll('-', '_'),
    fields: {
      ...flattenMedia(stripSystemFields(page.fields as Record<string, unknown>)),
      meta: synthesiseMeta(page),
      components,
    },
  }
}

Further Reading

Copyright © 2026