TentoCMS
Guides

Public API Usage

Integrating a frontend with the Public REST API — authentication and request patterns.

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/posts and GET /api/v1/collections/:type — see the Blog API Reference and the GET /api/v1/collections/:type section 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/client SDK: this guide shows raw fetch() calls for framework-agnostic clarity; if you'd rather not hand-roll a client, @tentocms/client wraps 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:

  1. Admin navigates to SettingsAPI Keys
  2. Admin clicks Create API Key
  3. 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:

ViewFieldsPayload Size
Card gridtitle,excerpt,image~3 KB
Navigation menutitle,slug~1 KB
Search resultstitle,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:

  1. Browser caching: Automatically handled by browser (respects Cache-Control)
  2. CDN caching: If using Cloudflare/CDN, cache is automatic
  3. 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 ETag header, but only the single-item page endpoint (GET /pages/:slug) actually honours If-None-Match and returns 304 Not Modified. The list endpoint (GET /pages) and the blog/collections endpoints set ETag for informational/debugging purposes but never check If-None-Match — sending it back has no effect, and you'll always get a fresh 200 body. Build ETag-based conditional-request caching against GET /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

  1. Create new API key in Admin UI
  2. Update environment variables in deployment platform
  3. Deploy with new key
  4. Verify application works
  5. 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:

  1. Use environment variables for API keys (never commit)
  2. Implement caching (browser, CDN, application)
  3. Use field selection to reduce payload size
  4. Handle errors gracefully (retry 5xx, show user-friendly messages)
  5. Implement redirects for SEO (301 status)
  6. Never expose keys in frontend code
  7. Use pagination for large result sets
  8. Parallelize requests when possible

Next Steps:


Last Updated: 2025-12-15

Copyright © 2026