TentoCMS
Api

Blog

Public read endpoints for blog posts, categories and tags.

Version: 1.0 Base URL: /api/v1Authentication: API key (public or secret)

For managing blog content (create/update/publish), see the Blog Admin API.


Overview

The public Blog API delivers published blog content over a read-only, API-key-authenticated interface. It exposes clean, cacheable endpoints for listing published posts, fetching a single post by slug, and reading the available categories and tags. Content is authored and managed through the Blog Admin API; this reference covers only the delivery side.

Key Features:

  • Read-only delivery of published blog content
  • Clean, cacheable responses with a delivery-oriented entity shape
  • Filtering by category and tag, with pagination and sorting
  • Rich taxonomy support with many-to-many tag relationships
  • Author attribution with public profile fields (email omitted)

Table of Contents

  1. Authentication & Authorization
  2. Public Blog API
  3. Data Models
  4. Error Handling
  5. Best Practices

Authentication & Authorization

Authentication

Public endpoints require a valid API key (public or secret), passed via the X-API-Key header or the api_key query parameter:

X-API-Key: tento_pk_abc123...

The API key scopes every request to a single tenant. By default only published content is returned (see Preview mode below for the exception). Cross-tenant access is prevented at the repository layer.

Tenant Isolation: All queries are automatically scoped to the tenant that owns the API key.

Preview mode: By default, only posts with status = 'published' are returned. If the request carries a valid preview key (?preview=<key> or X-Preview-Key header), GET /blog/posts also includes unpublished, draft, and scheduled posts. GET /blog/posts/:slug currently always requires status = 'published' — draft posts are not retrievable by slug even in preview mode.


Public Blog API

GET /blog/posts

List published blog posts (public endpoint).

Authentication: API key required (public or secret) Rate Limit: public read limits apply — see Limits & Errors

Query Parameters

ParameterTypeDefaultDescription
pagenumber1Page number
limitnumber20Items per page (max 100)
categoryIdstring-Filter by category UUID
tagIdstring-Filter by tag UUID
searchstring-Case-insensitive substring match against title/excerpt
sortstringpublishedAt DESCComma-separated sort fields; prefix a field with - for descending. Allowed fields: createdAt, updatedAt, publishedAt, title, slug. Example: sort=-publishedAt,title

Request Example

curl -X GET 'https://cms.example.com/api/v1/blog/posts?page=1&limit=10&categoryId=550e8400-e29b-41d4-a716-446655440000' \
  -H 'X-API-Key: tento_pk_abc123...'

Response (200 OK)

{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "title": "Getting Started with TentoCMS",
      "slug": "getting-started-with-tentocms",
      "excerpt": "Learn how to build powerful content experiences",
      "featuredImage": {
        "_mimeType": "image/jpeg",
        "_width": 1920,
        "_height": 1080,
        "_altText": "TentoCMS Dashboard",
        "_url": "https://cms.example.com/api/v1/media/tenant-123/featured.jpg"
      },
      "category": {
        "id": "cat-123",
        "name": "Tutorials",
        "slug": "tutorials",
        "color": "#3b82f6"
      },
      "tags": [
        {
          "id": "tag-123",
          "name": "CMS",
          "slug": "cms"
        }
      ],
      "author": {
        "id": "user-123",
        "name": "John Doe",
        "avatarUrl": "https://example.com/avatar.jpg",
        "bio": "Senior developer and tech writer",
        "jobTitle": "Lead Engineer",
        "twitterUrl": "https://twitter.com/johndoe",
        "linkedinUrl": "https://linkedin.com/in/johndoe",
        "githubUrl": "https://github.com/johndoe",
        "websiteUrl": "https://johndoe.dev"
      },
      "publishedAt": "2025-12-15T10:00:00.000Z",
      "updatedAt": "2025-12-18T09:30:00.000Z",
      "seo": {
        "metaTitle": "Getting Started with TentoCMS",
        "metaDescription": "Learn how to build powerful content experiences",
        "metaRobots": "index",
        "ogTitle": "Getting Started with TentoCMS",
        "ogDescription": "Learn how to build powerful content experiences",
        "ogImage": "https://cms.example.com/api/v1/media/tenant-123/featured.jpg",
        "canonicalUrl": null
      }
    }
  ],
  "pagination": {
    "total": 45,
    "page": 1,
    "limit": 10,
    "totalPages": 5
  }
}

Note: By default only returns posts with status = 'published'; a valid preview key (?preview=<key> or X-Preview-Key) also includes drafts — see Preview mode above. The content field is omitted from list items by default; pass ?includeContent=true to include the full HTML body. updatedAt and seo are always present on every post (seo's fields fall back to title/excerpt when the post has no explicit SEO overrides, and ogImage falls back to the featured image URL).


GET /blog/posts/:slug

Get a single published blog post by slug (public endpoint).

Authentication: API key required (public or secret) Rate Limit: public read limits apply — see Limits & Errors

Path Parameters

ParameterTypeRequiredDescription
slugstringYesPost slug

Request Example

curl -X GET 'https://cms.example.com/api/v1/blog/posts/getting-started-with-tentocms' \
  -H 'X-API-Key: tento_pk_abc123...'

Response (200 OK)

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "title": "Getting Started with TentoCMS",
    "slug": "getting-started-with-tentocms",
    "excerpt": "Learn how to build powerful content experiences",
    "content": "<p>Welcome to TentoCMS...</p>",
    "featuredImage": {
      "_mimeType": "image/jpeg",
      "_width": 1920,
      "_height": 1080,
      "_altText": "TentoCMS Dashboard",
      "_url": "https://cms.example.com/api/v1/media/tenant-123/featured.jpg"
    },
    "category": {
      "id": "cat-123",
      "name": "Tutorials",
      "slug": "tutorials",
      "color": "#3b82f6"
    },
    "tags": [
      {
        "id": "tag-123",
        "name": "CMS",
        "slug": "cms"
      }
    ],
    "author": {
      "id": "user-123",
      "name": "John Doe",
      "avatarUrl": "https://example.com/avatar.jpg",
      "bio": "Senior developer and tech writer",
      "jobTitle": "Lead Engineer",
      "twitterUrl": "https://twitter.com/johndoe",
      "linkedinUrl": "https://linkedin.com/in/johndoe",
      "githubUrl": "https://github.com/johndoe",
      "websiteUrl": "https://johndoe.dev"
    },
    "publishedAt": "2025-12-15T10:00:00.000Z",
    "updatedAt": "2025-12-18T09:30:00.000Z",
    "seo": {
      "metaTitle": "Getting Started with TentoCMS",
      "metaDescription": "Learn how to build powerful content experiences",
      "metaRobots": "index",
      "ogTitle": "Getting Started with TentoCMS",
      "ogDescription": "Learn how to build powerful content experiences",
      "ogImage": "https://cms.example.com/api/v1/media/tenant-123/featured.jpg",
      "canonicalUrl": null
    }
  }
}

Note: This endpoint requires status = 'published' regardless of preview mode — draft posts cannot currently be fetched by slug even with a valid preview key. Use the preview-enabled GET /blog/posts list to access drafts.

Error Responses

404 Not Found

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Blog post not found"
  }
}

GET /blog/categories

List all public categories (public endpoint).

Authentication: API key required (public or secret) Rate Limit: public read limits apply — see Limits & Errors

Response (200 OK)

{
  "data": [
    {
      "id": "cat-123",
      "name": "Tutorials",
      "slug": "tutorials",
      "description": "Step-by-step guides",
      "color": "#3b82f6"
    }
  ]
}

GET /blog/categories/:slug

Get a single category by slug (public endpoint).

Authentication: API key required (public or secret) Rate Limit: public read limits apply — see Limits & Errors

Path Parameters

ParameterTypeRequiredDescription
slugstringYesCategory slug

Response (200 OK)

{
  "data": {
    "id": "cat-123",
    "name": "Tutorials",
    "slug": "tutorials",
    "description": "Step-by-step guides",
    "color": "#3b82f6"
  }
}

Error Responses

404 Not Found

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Category not found"
  }
}

GET /blog/tags

List all public tags (public endpoint).

Authentication: API key required (public or secret) Rate Limit: public read limits apply — see Limits & Errors

Response (200 OK)

{
  "data": [
    {
      "id": "tag-123",
      "name": "CMS",
      "slug": "cms"
    }
  ]
}

GET /blog/tags/:slug

Get a single tag by slug (public endpoint).

Authentication: API key required (public or secret) Rate Limit: public read limits apply — see Limits & Errors

Path Parameters

ParameterTypeRequiredDescription
slugstringYesTag slug

Response (200 OK)

{
  "data": {
    "id": "tag-123",
    "name": "CMS",
    "slug": "cms"
  }
}

Error Responses

404 Not Found

{
  "error": {
    "code": "NOT_FOUND",
    "message": "Tag not found"
  }
}

Data Models

BlogPost

Complete blog post object.

interface BlogPost {
  id: string                    // UUID
  projectId: string              // Project UUID
  title: string                 // Post title (1-200 chars)
  slug: string                  // URL slug
  slugHistory: string[]         // Previous slugs for redirects
  excerpt: string | null        // Short description (max 500 chars)
  content: string               // HTML content
  featuredImageId: string | null // Featured image UUID
  featuredImage?: BlogMediaItem | null // Populated featured image
  categoryId: string | null     // Category UUID
  category?: BlogCategory       // Populated category
  tags: BlogTag[]              // Associated tags
  authorId: string             // Author user UUID
  author?: {                   // Populated author (public API omits email)
    id: string
    name: string
    avatarUrl: string | null
    bio: string | null
    jobTitle: string | null
    twitterUrl: string | null
    linkedinUrl: string | null
    githubUrl: string | null
    websiteUrl: string | null
  }
  status: BlogPostStatus       // 'draft' | 'published' | 'scheduled' | 'unpublished'
  publishedAt: string | null   // ISO 8601 timestamp
  scheduledAt: string | null   // ISO 8601 timestamp (for scheduled publishing)
  version: number              // Optimistic locking version
  seo: SeoMetadata             // SEO metadata (always present — see the Admin API's SEO docs for the full shape)
  createdBy: string            // User UUID
  updatedBy: string            // User UUID
  createdAt: string            // ISO 8601 timestamp
  updatedAt: string            // ISO 8601 timestamp
  deletedAt: string | null     // Soft delete timestamp
}

interface BlogMediaItem {
  url: string             // Raw R2 key at the repository/admin-API layer (e.g. "proj-1/hero.jpg")
  filename: string
  mimeType: string
  width: number | null
  height: number | null
  altText: string | null
}

Note: The public API returns a trimmed, delivery-oriented projection of this model. It omits internal fields (projectId, createdBy, updatedBy, deletedAt, and typically slugHistory/version), drops the author's email, and represents featuredImage with underscore-prefixed keys (_url, _altText, _width, _height, _mimeType) rather than the raw BlogMediaItem object. seo is always present in the public response too, but as a flattened, fallback-resolved object (metaTitle/metaDescription default to title/excerpt, ogImage is a resolved URL string, not an ID) — see the response examples above for its exact public shape. The full raw entity is returned by the Blog Admin API.

BlogCategory

interface BlogCategory {
  id: string                 // UUID
  projectId: string           // Project UUID
  name: string               // Category name (1-100 chars)
  slug: string               // URL slug
  description: string | null // Description (max 500 chars)
  color: string | null       // Hex color (e.g., "#3b82f6")
  sortOrder: number          // Display order
  createdBy: string          // User UUID
  updatedBy: string          // User UUID
  createdAt: string          // ISO 8601 timestamp
  updatedAt: string          // ISO 8601 timestamp
  deletedAt: string | null   // Soft delete timestamp
}

BlogTag

interface BlogTag {
  id: string                // UUID
  projectId: string          // Project UUID
  name: string              // Tag name (1-50 chars)
  slug: string              // URL slug
  createdBy: string         // User UUID
  updatedBy: string         // User UUID
  createdAt: string         // ISO 8601 timestamp
  updatedAt: string         // ISO 8601 timestamp
  deletedAt: string | null  // Soft delete timestamp
}

Error Handling

Error Response Format

All error responses follow this structure:

{
  "error": {
    "code": "ERROR_CODE",
    "message": "Human-readable error message"
  }
}

Common Error Codes

These are the codes this read-only public API can actually return:

CodeHTTP StatusDescription
UNAUTHORIZED401Missing, malformed, invalid, or expired API key
NOT_FOUND404Post, category, or tag not found (or wrong tenant)
INVALID_QUERY400Invalid page/limit/categoryId/tagId/search query parameters
INVALID_FILTER_FIELD400filter[field][op]= references a field outside the allowed core-field whitelist (title, slug, categoryId, status)
INVALID_SORT_FIELDS400sort= references a field outside the allowed whitelist
JSON_FIELDS_NOT_SUPPORTED400filter[fields.x][op]= used — blog posts have no JSON field column, unlike pages/collections
RATE_LIMITED429Too many requests
INTERNAL_ERROR500Server error

Note: This read-only endpoint never returns write-only error codes. FORBIDDEN (403), VERSION_CONFLICT (409), and slug-conflict errors (the real code is DUPLICATE_SLUG, not SLUG_CONFLICT) only apply to the session-authenticated Blog Admin API, which can create/update posts, categories, and tags.


Best Practices

Search and Filtering

Use the Server-Side search Parameter

Prefer the ?search= query parameter over fetching a large page and filtering client-side — it matches against title/excerpt in the database and returns only the matching page of results:

const response = await fetch(`/api/v1/blog/posts?search=${encodeURIComponent(searchTerm)}`, {
  headers: { 'X-API-Key': apiKey }
})
const { data: posts, pagination } = await response.json()

This scales correctly regardless of how many posts exist, unlike fetching ?limit=100 and filtering in the client (which is capped at 100 items per page and still does unnecessary work for every request).

Debounce Search Input

import { debounce } from 'lodash-es'

const debouncedSearch = debounce(async (query) => {
  await fetchPosts({ search: query })
}, 300)

Public API Caching

Server-Side Caching

Blog responses are cached server-side (KV), and every response carries an X-Cache: HIT or X-Cache: MISS header so you can observe cache behaviour. This header is diagnostic only — it tells you whether the response came from TentoCMS's internal KV cache or a fresh D1 query, but it isn't something your client needs to read or act on; see X-Cache Header in the main API reference for details. Unlike the Pages API, the blog routes do not set Cache-Control or ETag on their responses — there's no browser/CDN-level cache contract to rely on here, and preview responses are explicitly marked Cache-Control: private, no-store, max-age=0 so they're never cached by a shared cache. If you need HTTP-level caching, add your own Cache-Control at your CDN/reverse-proxy layer, or implement client-side caching (below).

Client-Side Caching

Implement client-side caching for better performance:

const cache = new Map()

async function fetchPost(slug) {
  if (cache.has(slug)) {
    return cache.get(slug)
  }

  const response = await fetch(`/api/v1/blog/posts/${slug}`)
  const post = await response.json()

  cache.set(slug, post)

  // Clear cache after 5 minutes
  setTimeout(() => cache.delete(slug), 5 * 60 * 1000)

  return post
}

Image Optimization

Optimize Image Display

Use responsive images in templates:

<img
  :src="`${post.featuredImage._url}?width=800&fit=cover`"
  :srcset="`
    ${post.featuredImage._url}?width=400 400w,
    ${post.featuredImage._url}?width=800 800w,
    ${post.featuredImage._url}?width=1200 1200w
  `"
  sizes="(max-width: 768px) 100vw, 800px"
  :alt="post.featuredImage._altText"
  loading="lazy"
/>

Error Handling

Handle All Error Codes

async function handleBlogRequest(request) {
  try {
    const response = await fetch(request)

    if (!response.ok) {
      const error = await response.json()

      switch (error.error.code) {
        case 'UNAUTHORIZED':
          // API key missing/invalid — check your X-API-Key header
          toast.error('API key is missing or invalid')
          break

        case 'NOT_FOUND':
          // Show 404
          router.push('/404')
          break

        case 'RATE_LIMITED':
          toast.error('Too many requests — please try again shortly')
          break

        default:
          // Generic error
          toast.error(error.error.message)
      }

      return
    }

    return await response.json()
  } catch (err) {
    console.error('Network error:', err)
    toast.error('Failed to connect to server')
  }
}

Summary

The public Blog API provides clean, cacheable read access to published blog content:

  • Delivery-Oriented Shape: Trimmed responses with public author fields and underscore-keyed media
  • Flexible Taxonomy: Filter by user-editable categories and tags
  • Cacheable: Server-side KV caching with X-Cache: HIT|MISS (no Cache-Control/ETag on these responses — see Public API Caching)
  • Many-to-Many Tags: Full support for multiple tags per post

Key Features:

  • 6 public endpoints for content delivery
  • Published-only content by default (drafts and unpublished posts are returned only to preview-key requests on the list endpoint — see Preview mode)
  • Category color coding for UI
  • Featured images with Cloudflare Image Resizing support
  • Author attribution with public profile fields

For content management (create, update, publish, categories, and tags), see the Blog Admin API.

Related Documentation:


Last Updated: 2026-07-06

Copyright © 2026