Public API Usage
Guide for frontend developers integrating with the TentoCMS Public REST API — authentication, request patterns, caching, and error handling, illustrated primarily against the Pages endpoints (the patterns apply identically to Blog and Collections; see the cross-references below).
Overview
This guide shows you how to integrate the TentoCMS Public REST API into your frontend applications. You'll learn how to:
- Set up authentication with API keys
- Fetch pages and media
- Handle pagination and field selection
- Implement caching strategies
- Handle errors and redirects
- Integrate with popular frameworks (Next.js, Nuxt, React, Vue)
Not covered in depth here — see the linked reference docs instead:
- Blog and Collections endpoints: this guide's examples fetch pages; the same auth/pagination/caching/error patterns apply unchanged to
GET /api/v1/blog/postsandGET /api/v1/collections/:type— see the Blog API Reference and theGET /api/v1/collections/:typesection of the Public REST API Reference. - Preview Mode (fetching draft/scheduled content with a preview key): see Public REST API Reference § Preview Mode.
- The official
@tentocms/clientSDK: this guide shows rawfetch()calls for framework-agnostic clarity; if you'd rather not hand-roll a client,@tentocms/clientwraps auth, pagination, and typed responses — see the SDK docs.
Prerequisites
- API key from your TentoCMS administrator (How to get one)
- Basic knowledge of JavaScript/TypeScript
- Familiarity with your chosen framework
Getting Started
Step 1: Obtain API Key
Contact your TentoCMS administrator to create an API key:
- Admin navigates to Settings → API Keys
- Admin clicks Create API Key
- Admin copies the full key and sends it securely
Example key:
tento_pk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Step 2: Set Up Environment Variables
Store your API key in environment variables (never commit to git):
# .env.local
CMS_API_KEY=tento_pk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
CMS_API_BASE_URL=https://tento-api.intelligentlending.co.uk/api/v1
Add to .gitignore:
.env
.env.local
.env.*.local
Step 3: Make First Request
// lib/cms.js
const API_KEY = process.env.CMS_API_KEY
const BASE_URL = process.env.CMS_API_BASE_URL
export async function fetchPages() {
const response = await fetch(`${BASE_URL}/pages`, {
headers: {
'X-API-Key': API_KEY
}
})
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
return await response.json()
}
// Usage
const { data, pagination } = await fetchPages()
console.log(`Found ${data.length} pages`)
Step 4: Handle Responses
All successful responses have this structure:
{
"data": [...],
"pagination": {
"total": 45,
"page": 1,
"limit": 20,
"totalPages": 3
}
}
Access page data:
const { data, pagination } = await fetchPages()
data.forEach(page => {
console.log(page.slug) // "about-us"
console.log(page.fields.title) // "About Us"
})
if (pagination.page < pagination.totalPages) {
console.log('More pages available')
}
Common Patterns
Fetching Page List with Pagination
Goal: Fetch all blog posts, 20 at a time
async function fetchBlogPosts(page = 1, limit = 20) {
const response = await fetch(
`${BASE_URL}/pages?pageType=blog-post&limit=${limit}&page=${page}`,
{ headers: { 'X-API-Key': API_KEY } }
)
const { data, pagination } = await response.json()
return {
posts: data,
totalPages: pagination.totalPages,
hasNextPage: pagination.page < pagination.totalPages
}
}
// Usage
const page1 = await fetchBlogPosts(1) // First 20 posts
const page2 = await fetchBlogPosts(2) // Next 20 posts
Fetching Single Page by Slug
Goal: Fetch the "About Us" page
async function fetchPageBySlug(slug) {
const response = await fetch(
`${BASE_URL}/pages/${slug}`,
{ headers: { 'X-API-Key': API_KEY } }
)
if (response.status === 404) {
return null // Page not found
}
const { data, redirect } = await response.json()
return { page: data, redirect }
}
// Usage
const result = await fetchPageBySlug('about-us')
if (!result) {
console.log('Page not found')
} else if (result.redirect) {
console.log(`Redirected from ${result.redirect.from} to ${result.redirect.to}`)
}
Handling Slug Redirects
When a page slug changes, the API returns the page with a redirect object:
{
"data": {...},
"redirect": {
"from": "about",
"to": "about-us",
"permanent": true
}
}
SEO Best Practice: Return 301 redirect to update URLs
// Next.js
export async function getStaticProps({ params }) {
const result = await fetchPageBySlug(params.slug)
if (!result) {
return { notFound: true }
}
// Handle redirect (301 for SEO)
if (result.redirect) {
return {
redirect: {
destination: `/${result.redirect.to}`,
permanent: true
}
}
}
return {
props: { page: result.page },
revalidate: 60
}
}
Using Field Selection for Performance
Problem: Fetching full page content when you only need title and excerpt
// Bad: Returns all fields (~15 KB per page)
const pages = await fetch(`${BASE_URL}/pages`)
// Good: Returns only title and excerpt (~2 KB per page)
const pages = await fetch(
`${BASE_URL}/pages?fields=title,excerpt`
)
Use Cases:
| View | Fields | Payload Size |
|---|---|---|
| Card grid | title,excerpt,image | ~3 KB |
| Navigation menu | title,slug | ~1 KB |
| Search results | title,excerpt,publishDate | ~2 KB |
| Full page | (omit fields param) | ~15 KB |
Example: Blog post cards
async function fetchBlogPostCards() {
const response = await fetch(
`${BASE_URL}/pages?pageType=blog-post&fields=title,excerpt,coverImage,publishDate`,
{ headers: { 'X-API-Key': API_KEY } }
)
return await response.json()
}
// Result: Only requested fields in response
{
"data": [{
"id": "page_123",
"slug": "hello-world",
"type": "blog-post",
"fields": {
"title": "Hello World",
"excerpt": "My first post",
"coverImage": "media_123",
"publishDate": "2025-12-15"
}
}]
}
Lighter Media with ?media=url
By default, media fields resolve to a full object (_url, _mimeType, _width, _height, _altText). If your frontend only needs the image URL, add ?media=url to get the URL string instead — a smaller payload:
// Default: media as objects
await fetch(`${BASE_URL}/pages/hello-world`)
// → fields.hero.icon = { "_url": "https://.../abc.webp", "_width": 112, ... }
// Lighter: media as URL strings
await fetch(`${BASE_URL}/pages/hello-world?media=url`)
// → fields.hero.icon = "https://.../abc.webp"
Keep the default object mode when you need image dimensions (responsive srcset, avoiding layout shift) or alt text (accessibility). Applies to pages, collections, and blog featuredImage.
Implementing Client-Side Caching
Goal: Avoid redundant API calls for the same data
class CachedCMSClient {
constructor(apiKey, cacheTTL = 60000) {
this.apiKey = apiKey
this.cacheTTL = cacheTTL
this.cache = new Map()
}
async fetch(endpoint) {
const cached = this.cache.get(endpoint)
// Return cached if fresh
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
console.log('Cache hit:', endpoint)
return cached.data
}
// Fetch fresh data
console.log('Cache miss:', endpoint)
const response = await fetch(`${BASE_URL}${endpoint}`, {
headers: { 'X-API-Key': this.apiKey }
})
const data = await response.json()
// Store in cache
this.cache.set(endpoint, {
data,
timestamp: Date.now()
})
return data
}
clearCache() {
this.cache.clear()
}
}
// Usage
const client = new CachedCMSClient(API_KEY, 60000) // 60s TTL
const pages1 = await client.fetch('/pages') // API call
const pages2 = await client.fetch('/pages') // Cached (instant)
Framework Integration Examples
Next.js
Static Site Generation (SSG)
Fetch at build time:
// pages/blog/[slug].js
export async function getStaticPaths() {
const response = await fetch(`${BASE_URL}/pages?pageType=blog-post`, {
headers: { 'X-API-Key': process.env.CMS_API_KEY }
})
const { data } = await response.json()
return {
paths: data.map(page => ({ params: { slug: page.slug } })),
fallback: 'blocking'
}
}
export async function getStaticProps({ params }) {
const response = await fetch(`${BASE_URL}/pages/${params.slug}`, {
headers: { 'X-API-Key': process.env.CMS_API_KEY }
})
if (response.status === 404) {
return { notFound: true }
}
const { data, redirect } = await response.json()
if (redirect) {
return {
redirect: {
destination: `/${redirect.to}`,
permanent: true
}
}
}
return {
props: { page: data },
revalidate: 60 // ISR: Revalidate every 60 seconds
}
}
export default function BlogPost({ page }) {
return (
<article>
<h1>{page.fields.title}</h1>
<div dangerouslySetInnerHTML={{ __html: page.fields.body }} />
</article>
)
}
Server-Side Rendering (SSR)
Fetch on each request:
// pages/blog/[slug].js
export async function getServerSideProps({ params }) {
const response = await fetch(`${BASE_URL}/pages/${params.slug}`, {
headers: { 'X-API-Key': process.env.CMS_API_KEY }
})
if (response.status === 404) {
return { notFound: true }
}
const { data } = await response.json()
return {
props: { page: data }
}
}
export default function BlogPost({ page }) {
return (
<article>
<h1>{page.fields.title}</h1>
<div dangerouslySetInnerHTML={{ __html: page.fields.body }} />
</article>
)
}
API Routes (Proxy Pattern)
Hide API key from frontend:
// pages/api/cms/pages/[slug].js
export default async function handler(req, res) {
const { slug } = req.query
const response = await fetch(`${BASE_URL}/pages/${slug}`, {
headers: { 'X-API-Key': process.env.CMS_API_KEY }
})
if (!response.ok) {
return res.status(response.status).json({ error: 'Page not found' })
}
const data = await response.json()
res.json(data)
}
// Client-side usage
const response = await fetch(`/api/cms/pages/about-us`)
const { data } = await response.json()
Nuxt
Server Routes (Nuxt 3)
Fetch at build time or runtime:
// server/api/pages/[slug].ts
export default defineEventHandler(async (event) => {
const slug = event.context.params.slug
const response = await $fetch(`${BASE_URL}/pages/${slug}`, {
headers: { 'X-API-Key': process.env.CMS_API_KEY }
})
if (!response) {
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
}
return response
})
Composable
// composables/useCMS.ts
export function useCMS() {
const config = useRuntimeConfig()
async function fetchPages(options = {}) {
const params = new URLSearchParams({
limit: options.limit || '20',
page: options.page || '1',
...(options.pageType && { pageType: options.pageType }),
...(options.fields && { fields: options.fields.join(',') })
})
return await $fetch(`${config.public.cmsBaseUrl}/pages?${params}`, {
headers: { 'X-API-Key': config.cmsApiKey }
})
}
async function fetchPage(slug) {
return await $fetch(`${config.public.cmsBaseUrl}/pages/${slug}`, {
headers: { 'X-API-Key': config.cmsApiKey }
})
}
return {
fetchPages,
fetchPage
}
}
// Usage in component
<script setup>
const { fetchPage } = useCMS()
const route = useRoute()
const { data: page } = await useAsyncData(
`page-${route.params.slug}`,
() => fetchPage(route.params.slug)
)
</script>
Page Component
<!-- pages/blog/[slug].vue -->
<script setup>
const { fetchPage } = useCMS()
const route = useRoute()
const { data: page, error } = await useAsyncData(
`page-${route.params.slug}`,
() => fetchPage(route.params.slug)
)
if (error.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found' })
}
// Handle redirects
if (page.value?.redirect) {
await navigateTo(`/${page.value.redirect.to}`, { redirectCode: 301 })
}
</script>
<template>
<article v-if="page">
<h1>{{ page.data.fields.title }}</h1>
<div v-html="page.data.fields.body" />
</article>
</template>
React
Custom Hook with SWR
// hooks/useCMS.js
import useSWR from 'swr'
const fetcher = async (url) => {
const response = await fetch(url, {
headers: { 'X-API-Key': process.env.REACT_APP_CMS_API_KEY }
})
if (!response.ok) {
throw new Error('API error')
}
return await response.json()
}
export function usePages(options = {}) {
const params = new URLSearchParams({
limit: options.limit || '20',
page: options.page || '1',
...(options.pageType && { pageType: options.pageType })
})
const { data, error, isLoading } = useSWR(
`${process.env.REACT_APP_CMS_BASE_URL}/pages?${params}`,
fetcher
)
return {
pages: data?.data,
pagination: data?.pagination,
isLoading,
error
}
}
export function usePage(slug) {
const { data, error, isLoading } = useSWR(
`${process.env.REACT_APP_CMS_BASE_URL}/pages/${slug}`,
fetcher
)
return {
page: data?.data,
redirect: data?.redirect,
isLoading,
error
}
}
// Usage in component
function BlogPost({ slug }) {
const { page, redirect, isLoading, error } = usePage(slug)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error loading page</div>
if (redirect) {
// Handle redirect
window.location.href = `/${redirect.to}`
return null
}
return (
<article>
<h1>{page.fields.title}</h1>
<div dangerouslySetInnerHTML={{ __html: page.fields.body }} />
</article>
)
}
Context Provider
// context/CMSContext.js
import { createContext, useContext } from 'react'
const CMSContext = createContext()
export function CMSProvider({ children, apiKey, baseUrl }) {
async function fetchPages(options) {
const params = new URLSearchParams(options)
const response = await fetch(`${baseUrl}/pages?${params}`, {
headers: { 'X-API-Key': apiKey }
})
return await response.json()
}
async function fetchPage(slug) {
const response = await fetch(`${baseUrl}/pages/${slug}`, {
headers: { 'X-API-Key': apiKey }
})
return await response.json()
}
return (
<CMSContext.Provider value={{ fetchPages, fetchPage }}>
{children}
</CMSContext.Provider>
)
}
export function useCMS() {
return useContext(CMSContext)
}
// App.js
function App() {
return (
<CMSProvider
apiKey={process.env.REACT_APP_CMS_API_KEY}
baseUrl={process.env.REACT_APP_CMS_BASE_URL}
>
<YourApp />
</CMSProvider>
)
}
Vue 3
Composable
// composables/useCMS.js
import { ref } from 'vue'
export function useCMS() {
const loading = ref(false)
const error = ref(null)
async function fetchPages(options = {}) {
loading.value = true
error.value = null
try {
const params = new URLSearchParams({
limit: options.limit || '20',
page: options.page || '1',
...(options.pageType && { pageType: options.pageType }),
...(options.fields && { fields: options.fields.join(',') })
})
const response = await fetch(
`${import.meta.env.VITE_CMS_BASE_URL}/pages?${params}`,
{ headers: { 'X-API-Key': import.meta.env.VITE_CMS_API_KEY } }
)
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
return await response.json()
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
async function fetchPage(slug) {
loading.value = true
error.value = null
try {
const response = await fetch(
`${import.meta.env.VITE_CMS_BASE_URL}/pages/${slug}`,
{ headers: { 'X-API-Key': import.meta.env.VITE_CMS_API_KEY } }
)
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
return await response.json()
} catch (e) {
error.value = e.message
throw e
} finally {
loading.value = false
}
}
return {
fetchPages,
fetchPage,
loading,
error
}
}
Component
<script setup>
import { ref, onMounted } from 'vue'
import { useCMS } from '@/composables/useCMS'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const { fetchPage, loading, error } = useCMS()
const page = ref(null)
onMounted(async () => {
try {
const result = await fetchPage(route.params.slug)
if (result.redirect) {
router.push(`/${result.redirect.to}`)
return
}
page.value = result.data
} catch (e) {
console.error('Failed to load page:', e)
}
})
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error }}</div>
<article v-else-if="page">
<h1>{{ page.fields.title }}</h1>
<div v-html="page.fields.body" />
</article>
</template>
Caching Strategy
Respecting Cache-Control Headers
The API includes cache headers:
Cache-Control: public, max-age=900, stale-while-revalidate=1800
ETag: "a1b2c3d4"
Recommended Client Strategy:
- Browser caching: Automatically handled by browser (respects Cache-Control)
- CDN caching: If using Cloudflare/CDN, cache is automatic
- Application caching: Implement SWR pattern for optimal UX
stale-while-revalidate Pattern
class SWRCache {
constructor(maxAge = 300000, staleTime = 600000) {
this.maxAge = maxAge
this.staleTime = staleTime
this.cache = new Map()
}
async fetch(key, fetcher) {
const cached = this.cache.get(key)
const now = Date.now()
if (!cached) {
// Cache miss: Fetch fresh
const data = await fetcher()
this.cache.set(key, { data, timestamp: now })
return data
}
const age = now - cached.timestamp
if (age < this.maxAge) {
// Fresh: Return immediately
return cached.data
}
if (age < this.maxAge + this.staleTime) {
// Stale: Return stale, revalidate in background
fetcher().then(data => {
this.cache.set(key, { data, timestamp: now })
})
return cached.data
}
// Expired: Fetch fresh
const data = await fetcher()
this.cache.set(key, { data, timestamp: now })
return data
}
}
// Usage
const cache = new SWRCache()
const pages = await cache.fetch('/pages', () =>
fetch(`${BASE_URL}/pages`, {
headers: { 'X-API-Key': API_KEY }
}).then(r => r.json())
)
Implementing ETag-Based Conditional Requests
304 support is per-endpoint, not universal. Every pages/blog/collections response sets an
ETagheader, but only the single-item page endpoint (GET /pages/:slug) actually honoursIf-None-Matchand returns304 Not Modified. The list endpoint (GET /pages) and the blog/collections endpoints setETagfor informational/debugging purposes but never checkIf-None-Match— sending it back has no effect, and you'll always get a fresh200body. BuildETag-based conditional-request caching againstGET /pages/:slug(as below), not against list endpoints.
class ETAGCache {
constructor() {
this.cache = new Map()
}
async fetch(url, options = {}) {
const cached = this.cache.get(url)
// Add If-None-Match header if we have an ETag
const headers = {
...options.headers,
...(cached?.etag && { 'If-None-Match': cached.etag })
}
const response = await fetch(url, { ...options, headers })
if (response.status === 304) {
// Not modified: Return cached data
console.log('304 Not Modified, using cache')
return cached.data
}
const data = await response.json()
const etag = response.headers.get('ETag')
if (etag) {
this.cache.set(url, { data, etag })
}
return data
}
}
// Usage — the single-page endpoint supports 304; the list endpoint (/pages) does not
const cache = new ETAGCache()
const page = await cache.fetch(`${BASE_URL}/pages/about-us`, {
headers: { 'X-API-Key': API_KEY }
})
Error Handling
Retry Logic for 5xx Errors
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url, options)
// Success (2xx) or client error (4xx): Don't retry
if (response.ok || response.status < 500) {
return response
}
// Server error (5xx): Retry with exponential backoff
const backoff = 2 ** attempt * 1000 // 1s, 2s, 4s
console.log(`Server error, retrying in ${backoff}ms...`)
await new Promise(resolve => setTimeout(resolve, backoff))
} catch (error) {
// Network error: Retry
if (attempt === maxRetries - 1) throw error
const backoff = 2 ** attempt * 1000
console.log(`Network error, retrying in ${backoff}ms...`)
await new Promise(resolve => setTimeout(resolve, backoff))
}
}
throw new Error('Max retries exceeded')
}
// Usage
const response = await fetchWithRetry(`${BASE_URL}/pages`, {
headers: { 'X-API-Key': API_KEY }
})
User-Friendly Messages for 4xx Errors
async function fetchPage(slug) {
const response = await fetch(`${BASE_URL}/pages/${slug}`, {
headers: { 'X-API-Key': API_KEY }
})
if (response.status === 401) {
throw new Error('Authentication failed. Please check your API key.')
}
if (response.status === 404) {
return null // Page not found (not an error, just return null)
}
if (response.status === 429) {
throw new Error('Too many requests. Please wait a moment and try again.')
}
if (!response.ok) {
const error = await response.json()
throw new Error(error.error.message || 'Failed to fetch page')
}
return await response.json()
}
// Display user-friendly error
try {
const result = await fetchPage('about-us')
} catch (error) {
showNotification(error.message, 'error')
}
Rate Limit Backoff Strategies
async function fetchWithRateLimitHandling(url, options = {}) {
const response = await fetch(url, options)
if (response.status === 429) {
// Get retry-after header (seconds to wait)
const retryAfter = parseInt(response.headers.get('Retry-After') || '60')
console.log(`Rate limited, waiting ${retryAfter}s...`)
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
// Retry once
return await fetch(url, options)
}
return response
}
// Usage
const response = await fetchWithRateLimitHandling(`${BASE_URL}/pages`, {
headers: { 'X-API-Key': API_KEY }
})
Performance Optimization
Field Selection to Reduce Payload
Impact: Up to 90% smaller responses
// Bad: 15 KB per page
const pages = await fetch(`${BASE_URL}/pages`)
// Good: 2 KB per page
const pages = await fetch(`${BASE_URL}/pages?fields=title,excerpt`)
// Best: 1 KB per page (navigation menu)
const pages = await fetch(`${BASE_URL}/pages?fields=title`)
Pagination Best Practices
// Bad: limit is capped server-side at 100 — this doesn't slow-fetch everything,
// it fails immediately with 400 INVALID_PAGINATION before any data is returned.
const allPages = await fetch(`${BASE_URL}/pages?limit=10000`)
// Good: Paginate with a limit of 100 or less
async function fetchAllPages() {
const pages = []
let page = 1
const limit = 100
while (true) {
const response = await fetch(
`${BASE_URL}/pages?limit=${limit}&page=${page}`
)
const { data, pagination } = await response.json()
pages.push(...data)
if (pagination.page >= pagination.totalPages) break
page += 1
}
return pages
}
Parallel Requests with Promise.all
// Bad: Sequential (slow)
const homePage = await fetchPage('home')
const aboutPage = await fetchPage('about')
const contactPage = await fetchPage('contact')
// Good: Parallel (fast)
const [homePage, aboutPage, contactPage] = await Promise.all([
fetchPage('home'),
fetchPage('about'),
fetchPage('contact')
])
Request Deduplication
class RequestDeduplicator {
constructor() {
this.pending = new Map()
}
async fetch(key, fetcher) {
// If request already in flight, wait for it
if (this.pending.has(key)) {
return await this.pending.get(key)
}
// Start new request
const promise = fetcher().finally(() => {
this.pending.delete(key)
})
this.pending.set(key, promise)
return await promise
}
}
// Usage
const dedup = new RequestDeduplicator()
// Multiple calls deduplicated to single request
const result1 = dedup.fetch('/pages', fetchPages)
const result2 = dedup.fetch('/pages', fetchPages) // Waits for first
const result3 = dedup.fetch('/pages', fetchPages) // Waits for first
Security
Never Expose API Keys in Frontend Code
Bad:
// ❌ NEVER DO THIS
const API_KEY = 'tento_pk_a1b2c3d4...'
fetch(`${BASE_URL}/pages`, {
headers: { 'X-API-Key': API_KEY }
})
Good (SSR/SSG):
// ✅ Server-side only
export async function getServerSideProps() {
const response = await fetch(`${BASE_URL}/pages`, {
headers: { 'X-API-Key': process.env.CMS_API_KEY }
})
return { props: { data: await response.json() } }
}
Good (Proxy Pattern):
// ✅ Frontend calls your API, which proxies to CMS
// Frontend:
const response = await fetch('/api/cms/pages')
// Your API:
export default async function handler(req, res) {
const response = await fetch(`${BASE_URL}/pages`, {
headers: { 'X-API-Key': process.env.CMS_API_KEY }
})
res.json(await response.json())
}
Key Rotation Procedures
- Create new API key in Admin UI
- Update environment variables in deployment platform
- Deploy with new key
- Verify application works
- After 24 hours, revoke old key
Zero-downtime rotation:
// Support multiple keys during rotation
const API_KEYS = [
process.env.CMS_API_KEY_NEW,
process.env.CMS_API_KEY_OLD
]
async function fetchWithFallback(url) {
for (const key of API_KEYS) {
try {
const response = await fetch(url, {
headers: { 'X-API-Key': key }
})
if (response.ok || response.status !== 401) {
return response
}
} catch (e) {
continue
}
}
throw new Error('All API keys failed')
}
Troubleshooting
401 Unauthorized → Check Key
// Debug API key issues
async function debugApiKey() {
const apiKey = process.env.CMS_API_KEY
console.log('API Key prefix:', apiKey?.substring(0, 12) + '...')
console.log('API Key length:', apiKey?.length)
console.log('API Key format valid:', !!apiKey?.match(/^tento_(pk|sk)_[a-z0-9]{32}$/))
if (!apiKey) {
console.error('❌ API key not set')
return
}
if (apiKey.length !== 41) {
console.error('❌ API key wrong length (should be 41 chars)')
return
}
// Test API call
const response = await fetch(`${BASE_URL}/pages?limit=1`, {
headers: { 'X-API-Key': apiKey }
})
if (response.status === 401) {
console.error('❌ API key invalid or expired')
} else {
console.log('✅ API key valid')
}
}
404 Not Found → Check Slug, Check Published Status
// Debug 404 errors
async function debugPageNotFound(slug) {
console.log(`Checking page: ${slug}`)
// Try fetching
const response = await fetch(`${BASE_URL}/pages/${slug}`, {
headers: { 'X-API-Key': API_KEY }
})
if (response.status === 404) {
console.log('❌ Page not found or not published')
console.log('Possible causes:')
console.log('1. Slug is incorrect (check spelling)')
console.log('2. Page exists but not published')
console.log('3. Page was deleted')
console.log('4. API key is for wrong tenant')
// Try fetching all pages to see available slugs
const allPages = await fetch(`${BASE_URL}/pages?limit=100`, {
headers: { 'X-API-Key': API_KEY }
})
const { data } = await allPages.json()
console.log('Available slugs:')
data.forEach(page => console.log(` - ${page.slug}`))
}
}
429 Rate Limited → Implement Backoff
// Auto-retry with backoff on rate limit
async function smartFetch(url, options = {}, maxRetries = 3) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await fetch(url, options)
if (response.status !== 429) {
return response
}
const retryAfter = parseInt(response.headers.get('Retry-After') || '60')
console.log(`Rate limited (attempt ${attempt + 1}), waiting ${retryAfter}s`)
await new Promise(resolve => setTimeout(resolve, retryAfter * 1000))
}
throw new Error('Rate limit exceeded after retries')
}
Summary
Key Takeaways:
- Use environment variables for API keys (never commit)
- Implement caching (browser, CDN, application)
- Use field selection to reduce payload size
- Handle errors gracefully (retry 5xx, show user-friendly messages)
- Implement redirects for SEO (301 status)
- Never expose keys in frontend code
- Use pagination for large result sets
- Parallelize requests when possible
Next Steps:
Last Updated: 2025-12-15

