Content Shape
The InvertContent interface — the normalized shape all adapters produce.
Content Shape
All content in Invert normalizes to a single InvertContent interface defined in src/adapters/interface.ts. Adapters are responsible for mapping their source format to this shape. The renderer and router only depend on the required fields.
InvertContent
interface InvertContent {
// Required
id: string; // Unique identifier (adapter-determined)
slug: string; // URL-friendly identifier — used in routing
title: string; // Display title
body: string; // HTML string — adapters convert markdown, etc.
contentType: string; // e.g. "posts", "pages", "docs"
// Optional
date?: string; // ISO 8601 date string
modified?: string; // ISO 8601 date string
author?: string; // Author name or identifier
excerpt?: string; // Short summary — used for og:description
featuredImage?: string; // URL or relative path — used for og:image
taxonomies?: Record<string, string[]>; // e.g. { tags: ["astro"] }
meta?: Record<string, unknown>; // Arbitrary pass-through data
status?: 'draft' | 'published'; // undefined treated as published
}
Content types are strings
There is no structural distinction between a "post" and a "page" and a "recipe". A content type is a string. Directory structure can optionally mirror content types (e.g. content/posts/) but this is convention, not architecture. The system does not enforce schemas per content type — that's the adapter's job if it wants to.
Routing
Content is routed by contentType and slug:
/posts/my-post→{ contentType: "posts", slug: "my-post" }/docs/getting-started→{ contentType: "docs", slug: "getting-started" }/pages/about→{ contentType: "pages", slug: "about" }
Listing pages at /{type}/ show all content of that type.
The body field
The body field must be an HTML string. Adapters that read Markdown must convert it to HTML before returning it. The ContentBody component renders it with set:html.
If your adapter reads from an API that returns Markdown, convert it with remark/rehype before returning. If it returns HTML directly (e.g. WordPress REST API), pass it through as-is.
Draft content
Set status: "draft" on any content item to exclude it from the public site. Draft content is not built at its canonical URL (/[type]/[slug]) and is invisible to all content API functions by default.
Draft content is accessible at /preview/[type]/[slug] with a visible draft banner and noindex meta. On Cloudflare Pages this is served by a dynamic Pages Function reading from KV — no rebuild required. Share the URL for review before publishing.
To publish a draft: if you created it via the MCP, call invert_publish(contentType, slug). If you created it by hand (JSON or markdown file), change status to "published" or remove the field.
{
"id": "my-draft",
"slug": "my-draft",
"title": "Work in Progress",
"contentType": "posts",
"body": "<p>...</p>",
"status": "draft"
}
Social / OG meta
excerpt maps to og:description and meta[name="description"]. featuredImage maps to og:image and twitter:image. Both are optional — if absent the relevant tags are omitted.
The canonical URL and og:url are derived automatically from Astro.site + the current pathname. Configure SITE_URL in your environment or astro.config.mjs to produce correct canonical URLs.
Using meta
The meta field is an escape hatch for data that doesn't fit the standard shape. Templates can read from content.meta for source-specific fields without breaking the interface contract.
{
"id": "my-post",
"slug": "my-post",
"title": "My Post",
"contentType": "posts",
"body": "<p>...</p>",
"meta": {
"wordpressId": 42,
"acf": { "custom_field": "value" }
}
}