ButterCMS Migration
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:
commitis at or after the verified SHA → you're good; proceed.commitis behind it (or reports"unknown") → the deployment predates this guide. Request a redeploy of@tentocms/apibefore 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
mainis not live until that environment is redeployed. The/healthcommitfield is the authoritative answer.
Prerequisites
- Content already imported via the TentoCMS migration wizard (run a dry run first)
@tentocms/clientinstalled in your project- TentoCMS API key available (public key
tento_pk_*for read-only, secret keytento_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:
- Schema — Create all component schemas in TentoCMS (verify completeness before proceeding)
- Content — Import content via the migration wizard (dry run, then full migration)
- Types — Generate TypeScript types with
@tentocms/typegenif using TypeScript - SDK install — Install
@tentocms/clientand initialise the client - Application code — Update data fetching calls, field references, and component renderers
- 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/utilslayer), 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,_sortOrderordering, name-filtering,pageTypefiltering). 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 varianttype. A component's owntypefield (e.g. a trustpilot block'stype: 'carousel') sits alongside the kebab-case_typediscriminant. If you naively settype = snakeCase(_type), you overwrite the variant. Instead, move every non-_typefield into a nestedfieldsobject first (so the variant lands atfields.type), then derivetypefrom_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
baseUrlexplicitly. 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 ishttps://tento-api.intelligentlending.co.uk. ATentoClientpointed at the wrong host fails with connection errors or 401s. Note that because a default exists, a missingbaseUrldoes 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 istento-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.fieldscontains only your custom content fields — no SEO, no identifierspage.typeis the page type slug (top-level, not insidefields)- SEO metadata is under
page.seo, not scattered throughpage.fields id,publishedAt, andupdatedAtare 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.metaTitlemay contain a leading type prefix —page.namewill not. The importer copies ButterCMS'smeta_titlevalue verbatim intoseo.metaTitle. ButterCMS users frequently setmeta_titleto strings like"Product Page: My Title"in the admin — that prefix is not stripped on import, sopage.seo.metaTitlecan come through as"Product Page: My Title"whilepage.nameis the clean"My Title". If you renderseo.metaTitleas 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 offieldsintopage.seoand leaves anything else behind. After import:
- Recognised SEO fields (
meta_title,meta_description,meta_canonical, …) are gone fromfields.meta(now underpage.seo), andpage.seois often only partially populated.- A non-SEO field (e.g.
page_path) that is a real field in your Butter schema survives infields.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-emptypage_pathcomes through asfields.meta = { page_path: "" }.- A container holding only recognised SEO fields is dropped entirely —
fields.metabecomesnull/undefined. So if you expectedpage_pathbutfields.metaisnull, the cause is thatpage_pathis not actually a defined field in your Buttermetaschema — 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, useseo.canonicalUrl(preserved whenmeta_canonicalwas set) — BINQ used exactly this fallback.This still bites:
data.fields.metabeingundefinedmeans readingdata.fields.meta.meta_canonicalwithout optional chaining throws at SSR and 500s the page. (Even iffields.metawere to survive on some pages, a moved field likemeta_canonicaljust readsundefinedthere — no crash, but no value either: read it frompage.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.metawill benull. Read SEO frompage.seo(or rebuild the old shape in a boundary adapter), and re-derive routing paths fromseo.canonicalUrlrather than relying onfields.meta.page_path. Also audit reference fields (e.g.author,category): these are frequently unset on real imported content — an unset single-reference resolves toundefined, so readingfields.author.namewithout optional chaining SSR-500s. Apply the same optional-chaining / adapter-default discipline to reference reads as you would tofields.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?.nameIf you adopt the boundary-adapter strategy, always synthesise a non-null
fields.metaobject (rebuilt frompage.seo), even when all values areundefined— so consumers that readfields.meta.meta_titlewithout optional chaining don't SSR-500 on pages where the container was dropped entirely. Also default the individual SEO field values to''(empty string), notnull/undefined, to match ButterCMS's empty-string semantics. Tento returnsseo.canonicalUrl: null(which becomesundefinedafterstripNulls) when unset; ButterCMS returnedmeta_canonical: "". Code likemeta_canonical ?? '/'behaves differently onundefinedvs""—??fires onundefinedbut not on"". So a page that returned""under ButterCMS (where?? '/'left it unchanged) can silently start emitting/as its canonical under Tento (which returnsundefined). (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 container | metaTitle |
meta_description / seo_description — or bare description | metaDescription |
meta_canonical / canonical_url — or bare canonical | canonicalUrl |
og_title / open_graph_title | ogTitle |
og_description / open_graph_description | ogDescription |
social_media_image / og_image / open_graph_image — or bare image | ogImage |
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.
metaRobotsis never auto-mapped — pages default toindex; set robots in TentoCMS after import if you need something else.ogImageonly 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 ametacontainer left with only recognised SEO fields is dropped entirely, so apage_paththat 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_page→main-page,legal_page→legal-page,campaign_page→campaign-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 publicGET /api/v1/schemasendpoint (API-key auth). The response is wrapped:{ data: { pageTypes, components, collections } }— read.datato reach the arrays.data.pageTypes[]lists every page type'sslugandnameregardless of publish state. (Listing pages —GET /api/v1/pages?limit=100— also surfaces each page'stype, 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-pagestype — not a rename. ButterCMS allows pages to have nopage_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" (sluglegacy-pages). A page appearing underlegacy-pageswas not renamed from something else — it simply had nopage_typeset in ButterCMS and landed in this catch-all.Watch out for heterogeneous typeless pages. The
legacy-pagestype'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 orundefinedon pages that don't match the sample. Discriminate on the top-leveltypefield of the source content and treatlegacy-pagesas a mixed bucket — don't assume all items in it have the same shape.Use
GET /api/v1/schemasto confirm the final slugs for every imported type, includinglegacy-pagesif 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, notAuthorization: Bearer. Any time this guide suggests calling an endpoint directly with curl or fetch, pass the key as theX-API-Key: <key>header (or?api_key=<key>query param). TheAuthorization: Bearer <key>header is not recognised and returns401 {"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 otherGETcalls 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/schemastells you what types exist now; the report tells you how each source name became that slug, which is what you need when updating discriminants likepage.type === '...'in application code.
- Top-level ButterCMS fields may move into
fields(e.g. abrandfield becomesfields.brand). - Watch for a redundant
fields.page_type. A custompage_typecontent field can survive alongside the real top-levelpage.type, with a different value. Always discriminate onpage.type, neverfields.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. Readingitem._slugoritem._namereturnsundefined. 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:
| Field | Type | Description |
|---|---|---|
_sortOrder | number | Sort position |
_publishedAt | string | Publication timestamp |
_updatedAt | string | Last 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()'sdatais the item itself (T), notT[]— the type isT[] | 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 field | TentoCMS field | Notes |
|---|---|---|
body | content | HTML or markdown |
summary | excerpt | Short description |
featured_image (string URL) | featuredImage (TentoMedia object) | See Image handling |
author.first_name + author.last_name | author.name | Combined into single field |
author.profile_image | author.avatarUrl | Rehosted 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_title | seo.metaTitle | Nested under seo object |
meta_description | seo.metaDescription | Nested under seo object |
status | status | Same concept |
tags[].name, tags[].slug | tags[].name, tags[].slug | Same structure |
categories[].name, categories[].slug | category.name, category.slug | Single category, not an array |
Fields with no Tento equivalent — compute or stub in your adapter:
| ButterCMS field | Tento equivalent | What to do |
|---|---|---|
read_time | — (absent) | Compute from content word count: Math.ceil(wordCount / 200) |
updated / updatedAt | updatedAt | Mapped 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_name | single author.name | Store 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 category | Wrap 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
dangerouslySetInnerHTMLto 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.contentor other rich text fields could inject<script>tags or event handlers (onclick,onerror, etc.) that execute in end-user browsers.Before using
dangerouslySetInnerHTMLin production:
- 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> ) }- 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> ) }- 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 type | TentoCMS _type |
|---|---|
hero | hero |
cta_banner | cta-banner |
quote_section | quote-section |
text_block | text-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
.fieldssub-object. Each nested/repeater component item is its content fields plus a kebab-case_typediscriminant; a component's own variant field (e.g.type) sits alongside_type, so read it ascomponent.type, notcomponent.fields.type. That is why the renderer above spreads{...component}, not{...component.fields}. If older docs or examples showcomponent.fields, they are wrong for the current API.
SDK Method Mapping
| ButterCMS | TentoCMS |
|---|---|
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()throwsTentoNotFoundErroron 404 — it does not returnnull. This applies totento.pages.getBySlug(),tento.collections.getBySlug(), andtento.blog.posts.getBySlug(). ButterCMS effectively returned empty/nullfor 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 returnnullat 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
pageTypeif slugs can collide.pages.getBySlug(slug)without apageTypeoption is ambiguous when the same slug exists under multiple page types (e.g.bankingas both amain-pageand aguides-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 throwsTentoNotFoundErroron an unknown type slug. The not-found guidance above focuses ongetBySlug, butcollections.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 type — tento.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 resolved — fields 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
publishedAtdescending — non-deterministic for bulk-imported content.pages.list()defaults topublishedAtdescending. 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 —slugornameorder page lists reliably (prefix form:name= ascending,-name= descending). (_sortOrderis 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 passingsort: '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 samejson_extractengine as collections. Passfilter[fields.<path>][<op>]=<value>query params (or the SDKfiltersarray), e.g.filter[fields.tier][eq]=pro(nested scalar paths likefields.hero.headlinework 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 returnedpagination.totalreflects the filtered set. (Collections filter the same way viafilters: [{ field, operator, value }].)Caveat: server-side filters run on the raw stored content, not resolved references. The
json_extractfilter reads the value as it exists in the database — an unresolved_refstring — 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, ifcategoryis a reference field that resolves to{ name, slug, _type }in the response, a filter onfields.category.namematches against the raw_reftoken, not the resolvedname— 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 singlelist()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'spaginationobject has{ total, page, limit, totalPages }(there is no cursor). Loop by incrementing thepagequery param whilepage < 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 throwsTentoNotFoundErroron 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
TentoMediahas noid, sogetImageUrl(id, …)cannot be used on images already resolved in page/collection content. A resolvedTentoMediaobject exposes only_url,_mimeType,_width,_height,_altText— there is noid/_id. For inline media, append transform params to_urldirectly: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'smedia.transformUrl(url, opts)appends transform params to a_urlstring for you.)
✔ Optional media is
TentoMedia | undefined— never"". As of the current API version, an unset MEDIA or IMAGE field returnsnullfrom the API, which the SDK'sstripNullsconverts toundefined. You will never receive""for an unset media field on a current deployment.A set image is a
TentoMediaobject; an unset one isundefined. 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?._urlat 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)]))) : vIf your adapter already treats
""as a no-op (i.e. the pass-through branch inflattenMediaabove 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.⚠️
flattenMediadiscards_altText. Collapsing aTentoMediaobject to its_urlstring drops every other field, including_altText. Where a separate alt field is expected — for example, blogfeatured_image_alt— capture_altTextbefore flattening:const alt = post.featuredImage?._altText ?? ''then callflattenMedia. This pattern is correct in general, but be aware that for imported blog posts specifically,_altTextwill be empty regardless — see thefeatured_image_altnote 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_updatedAtat 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 thequality=85default), so the old lossless-VP8L re-encoding path that produced files larger than the source is gone. For clarity and forward compatibility, always appendformat=autoorformat=webpexplicitly —format=autois 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/imageandnext/imageconsumers: 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'swidth/height/qualityprops — any params you append to_urlare dropped before the request is made. Pass_urldirectly as thesrcand let the image component manage transforms. (For remote images without intrinsic dimensions, NuxtImg may emit a degenerates_1x1placeholder src while the realsrcsetentries 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 tocdn.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.comto the TentoCMS API host (e.g.tento-api.intelligentlending.co.uk). Frameworks that proxy/optimise images must add it —@nuxt/imageimage.domains, Next.jsimages.remotePatterns, etc. — otherwise the image component rejects the URL.
For complete documentation on image transformations, see:
Supported transform parameters:
| Parameter | Values | Default |
|---|---|---|
width | 1–4000 | original |
height | 1–4000 | original |
fit | scale-down, contain, cover, crop (no pad mode) | scale-down |
quality | 1–100 | 85 |
format | webp, 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 |
gravity | auto, center, top, bottom, left, right | center |
dpr | 1–3 | 1 |
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, nevernull. The SDK'sstripNullsturns any explicitnullintoundefined(the key stays, valuedundefined), and fields the API or migration omits aren't present at all — e.g. a custommetaSEO 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.stripNullsconvertsnulltoundefined, butJSON.stringifyomits keys whose value isundefined— so when a TentoCMS response passes through a Nuxt server route or any other JSON proxy, an unset field such ashero_section.imagearrives at the browser absent from the object entirely (not asnull, not asundefined, not as""). Consumers usingx.image || fallbackorx.image ?? fallbackare fine — both handle an absent key the same asundefined. 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/schemasreports reference options in snake_case — but the list endpoint wants kebab-case. When you inspect a component schema viaGET /api/v1/schemas, reference field options list theircollectionTypein snake_case (e.g."guide_category","background_colours","success_story","authors"). These are not the slugs you pass tocollections.list()orGET /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/typegenoutput (*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-coloursreference 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 jsonfields are typedunknown, notRecord<string, unknown>, because ajsonfield 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
metafield can appear on collection interfaces if your content was imported by an older migration (before the adapter stripped ButterCMS's systemmeta). The live API never returnsmetaon collection items — re-run the migration, or remove themetafield 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: truefalls 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 setspreview: true) means published-only pages still render correctly. You will not see a blank page or aTentoNotFoundErrorsimply 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/pagesand the SDK'spages.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 whyGET /api/v1/schemasis 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()throwsTentoNotFoundError(it does not returnnull) 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:
| Endpoint | Body | Result |
|---|---|---|
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.
_typeis 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_typediscriminant. 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_typekey 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 →
nullfrom the API →undefinedvia the SDK (the SDK'sstripNullsconvertsnulltoundefined). 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 insiderepeateritems.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{}, notnullor absent. An unsetjsonfield (e.g. an unsetlocationor a card-levelbackground) resolves to{}. Reading.urlor.colouroff it yieldsundefined(safe), but guard accordingly — the field is present as{}, not missing.
⚠️ Collection-backed
authorreference: the image field isimage, notprofile. When a guide or page has anauthorfield that references theauthorscollection, the resolved object is{ name, title, image: TentoMedia, _type: 'authors' }. ButterCMS exposed the same author image underprofile— so code readingauthor.profilesilently getsundefinedafter migration. Rename all reads ofauthor.profiletoauthor.image(aTentoMediaobject, not a URL string). Note this is distinct from blog post authors, where the image isauthor.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.comallow-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 originalcdn.buttercms.comURL and records a warning in the Import report rather than failing the migration. Soauthor.avatarUrlmay come back as either a/media/…URL or acdn.buttercms.comURL. Unlike guide/page collection authors (whoseimagefield is reliably Tento-hosted), do not assume blog author avatars are rehosted:
- After import, check the Import report for avatar rehost warnings, and spot-check a few
author.avatarUrlvalues.- Keep
cdn.buttercms.comin your@nuxt/imageimage.domains/images.remotePatternsallow-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/undefinedin TentoCMS. This is the same class of problem as thefields.metacontainer described in Data Shape Differences: ButterCMS sent defined fields as""(nevernull), so an unsetauthorreference came back as{ name: '', title: '', profile: '' }— something truthy that deep reads could traverse safely. TentoCMS returnsnullfor unset references, which the SDK'sstripNullsconverts toundefined. An unguarded deep read likeguidePage.fields.author.name(orlocation.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 (
limitup to 100) over one request per item, cap your fetch concurrency, and on a429 RATE_LIMITEDadd backoff and honourRetry-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_timeon list items requiresincludeContent: true. The field-mapping table above advises computingread_timefrom the post'scontentword count (Math.ceil(wordCount / 200)), butcontentis absent on list items by default. To reproduce ButterCMS's behaviour — where the list response included the full post body and allowedread_timeto be shown on post cards — passincludeContent: truetoblog.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 nocontentand a computedread_timeis always0orundefined. The single-post endpoint always includescontent, 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
updatedAtwith the import timestamp. If you usepage.updatedAtas thelastmodvalue 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,updatedAtreflects 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/clientinstalled (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
TentoMediaobjects - Updated blog post field names (
bodytocontent,summarytoexcerpt,featured_imagetofeaturedImage) - Updated collection keys from snake_case to kebab-case
- Updated SEO field access from
page.fields.seo_titletopage.seo.metaTitle(all sevenseo.*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 thelegacy-pagescatch-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_nametopost.author?.name - Replaced
butter.category.list()/butter.tag.list()withtento.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
- Getting Started — filters, sorting, pagination, references
- Public API Reference
- Framework guides: Nuxt · Next.js · Astro
- API references: Blog · Media · Field Types
- SDK README
- Migration Wizard Guide
- Public API Usage

