Blog
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
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
| Parameter | Type | Default | Description |
|---|---|---|---|
page | number | 1 | Page number |
limit | number | 20 | Items per page (max 100) |
categoryId | string | - | Filter by category UUID |
tagId | string | - | Filter by tag UUID |
search | string | - | Case-insensitive substring match against title/excerpt |
sort | string | publishedAt DESC | Comma-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
| Parameter | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Post 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
| Parameter | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Category 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
| Parameter | Type | Required | Description |
|---|---|---|---|
slug | string | Yes | Tag 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:
| Code | HTTP Status | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing, malformed, invalid, or expired API key |
NOT_FOUND | 404 | Post, category, or tag not found (or wrong tenant) |
INVALID_QUERY | 400 | Invalid page/limit/categoryId/tagId/search query parameters |
INVALID_FILTER_FIELD | 400 | filter[field][op]= references a field outside the allowed core-field whitelist (title, slug, categoryId, status) |
INVALID_SORT_FIELDS | 400 | sort= references a field outside the allowed whitelist |
JSON_FIELDS_NOT_SUPPORTED | 400 | filter[fields.x][op]= used — blog posts have no JSON field column, unlike pages/collections |
RATE_LIMITED | 429 | Too many requests |
INTERNAL_ERROR | 500 | Server error |
Note: This read-only endpoint never returns write-only error codes.
FORBIDDEN(403),VERSION_CONFLICT(409), and slug-conflict errors (the real code isDUPLICATE_SLUG, notSLUG_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(noCache-Control/ETagon 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

