A database-less, adapter-driven content presentation layer built on Astro. Invert renders content from any source — JSON files, markdown, a WordPress site, a Drupal site, an AI tool over MCP — without an admin panel, without a database, and without opinions about where your content lives.
There is no admin. That's the point. If there's no admin, then the admin can be anything.
The presentation layer and the content management layer are separate concerns. Conflating them is the mistake the industry keeps making.
Invert doesn't replace your CMS. It sits in front of it — or in front of no CMS at all. Content comes in from adapters, gets normalized into a common JSON shape, and gets rendered. Where the content comes from is not Invert's problem. That's yours.
You can use WordPress and keep your admin panel. You can commit markdown to a git repo. You can talk to an AI tool over MCP and let it create content as JSON files. You can do all three at the same time. The choice is yours.
An inversion is when you flip yourself upside down — a fundamental move in aerial arts. You hold your whole body weight in your hands while you rotate 180 degrees. It's a test of grip and strength. In this context, Invert means: look at the same stuff from a different angle. We don't have to throw our toys away. We can have new things and still honor the old things.
- Node.js >= 22.12.0 (use nvm:
nvm use)
- Click "Use this template" on GitHub to create your own repository
- Clone your new repo locally
- Install dependencies:
npm install- Start the dev server:
npm run devYour site is running at http://localhost:4321.
Drop .json files into content/[type]/[slug].json. The directory name is the content type; the filename is the slug.
content/
posts/
my-post.json → /posts/my-post
pages/
about.json → /pages/about
A content file:
{
"id": "my-post",
"slug": "my-post",
"title": "My Post",
"body": "<p>Content here.</p>",
"contentType": "posts",
"date": "2026-04-06",
"author": "Chris",
"taxonomies": {
"tags": ["example"]
}
}Drop .md files into markdown/ with YAML frontmatter:
---
title: My Post
slug: my-post
contentType: posts
date: 2026-04-06
author: Chris
tags: [example]
---
Content here.Point the markdown adapter at a GitHub repository and Invert pulls content from it at build time:
// src/lib/config.ts
new MarkdownAdapter({
source: 'github',
repo: 'your-org/your-repo',
contentDir: 'content',
branch: 'main',
token: process.env.GITHUB_TOKEN, // optional, for private repos
})Start the MCP server and connect it to Claude Desktop or any MCP-compatible tool:
npm run mcpThe AI tool can then create, read, update, and search content. Write operations produce JSON files in content/ — the JSON adapter's write path. Markdown files (markdown/, docs/) are treated as externally managed source files and are not writable via MCP. See MCP write path below.
All configuration lives in src/lib/config.ts:
import { join } from 'node:path';
import { JsonAdapter } from '../adapters/json.ts';
import { MarkdownAdapter } from '../adapters/markdown.ts';
import { DocsAdapter } from '../adapters/docs.ts';
const root = process.cwd();
export const invertConfig = {
siteName: 'My Site',
siteUrl: 'https://example.com',
adapters: [
new JsonAdapter({ contentDir: join(root, 'content') }),
new MarkdownAdapter({ source: 'local', contentDir: join(root, 'markdown') }),
new DocsAdapter({ contentDir: join(root, 'docs') }),
],
};Paths must be absolute — use join(root, '...') where root = process.cwd(). Relative paths fail during Astro's static build phase.
Multiple adapters run simultaneously. Content merges from all sources. If two adapters return content with the same contentType and slug, the first adapter in the array wins.
All content from all adapters normalizes to InvertContent:
| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Unique identifier |
slug |
string | yes | URL slug — used in routing |
title |
string | yes | Display title |
body |
string | yes | HTML string |
contentType |
string | yes | e.g., "posts", "pages", "docs" |
date |
string | no | ISO 8601 date |
modified |
string | no | ISO 8601 date |
author |
string | no | Author name |
excerpt |
string | no | Short summary |
featuredImage |
string | no | URL or path to image |
taxonomies |
object | no | e.g., { tags: ["a", "b"] } |
meta |
object | no | Arbitrary pass-through data |
Content types are strings. There is no structural difference between a "post" and a "page" and a "recipe." The system doesn't enforce schemas — that's the adapter's job.
/— Landing page/[type]/— All content of a given type (e.g.,/posts/,/docs/)/[type]/[slug]— Individual content item (e.g.,/docs/getting-started)
Routes are generated at build time from whatever adapters return. No configuration needed.
Invert includes a Model Context Protocol server for AI tool integration (mcp/server.ts).
| Tool | Description |
|---|---|
invert_list |
List content, optionally filtered by type and paginated |
invert_get |
Get a single content item by type and slug |
invert_search |
Full-text search across title, body, excerpt |
invert_types |
List all available content types |
| Tool | Description |
|---|---|
invert_create |
Create a new content item |
invert_update |
Update an existing content item |
invert_delete |
Delete a content item |
Write tools create and modify JSON files in content/ only. This is intentional.
Invert treats content/ (JSON files) as the AI-writable layer. Markdown files in markdown/ and docs/ are externally managed — written by humans, pulled from git, or fetched from a remote source. The separation is the point: if you want an AI tool to author structured content items, it goes through MCP into content/. If you want to manage documentation or long-form writing, you edit the markdown files directly or in your source repo.
Changes written via MCP appear on the site after a rebuild, or immediately in dev mode with hot reload.
{
"mcpServers": {
"invert": {
"command": "npm",
"args": ["run", "mcp"],
"cwd": "/path/to/your/site"
}
}
}- JsonAdapter — Reads
.jsonfiles from a local directory. Directory structure maps to content types. - MarkdownAdapter — Reads
.mdfiles with YAML frontmatter. Supports local directories and remote GitHub repos. - DocsAdapter — Reads markdown from a
docs/directory and forcescontentType: "docs". Ships as a worked example of building a custom adapter — seesrc/adapters/docs.ts.
DocsAdapter is the reference implementation. The minimum viable adapter:
import type { InvertAdapter, InvertContent } from './interface.ts';
export class MyAdapter implements InvertAdapter {
name = 'my-adapter';
async getAll(): Promise<InvertContent[]> {
return []; // fetch and return InvertContent[]
}
async getBySlug(slug: string): Promise<InvertContent | null> {
const all = await this.getAll();
return all.find((c) => c.slug === slug) ?? null;
}
async getByType(contentType: string): Promise<InvertContent[]> {
const all = await this.getAll();
return all.filter((c) => c.contentType === contentType);
}
}Register it in src/lib/config.ts alongside existing adapters.
- WordPress REST API — Fetch posts, pages, and CPTs from a WordPress site
- Drupal JSON:API — Fetch content entities from Drupal
- Content Publisher — Pantheon Content Publisher for Google Docs workflows
- RSS/Atom — Feed entries as content items
- Generic HTTP — Configurable adapter for any JSON API
Invert builds to a static dist/ directory. No server-side runtime required.
The included .github/workflows/deploy-docs.yml builds and deploys on push to main. Enable Pages in your repo settings (Settings → Pages → Source: GitHub Actions).
For project pages (e.g. username.github.io/repo-name), the workflow sets SITE_BASE automatically from the repo name. For a custom domain, remove the SITE_BASE env var from the workflow.
Connect your GitHub repo in the Cloudflare Pages dashboard:
- Build command:
npm run build - Output directory:
dist - Environment variable:
NODE_VERSION = 22
Any static host works: Netlify, Vercel, S3, etc. Run npm run build and deploy dist/.
| Command | Description |
|---|---|
npm run dev |
Start Astro dev server at localhost:4321 |
npm run build |
Build for production to dist/ |
npm run preview |
Preview the production build locally |
npm run mcp |
Start the MCP server (stdio transport) |
invert/
├── astro.config.mjs # Astro configuration
├── CLAUDE.md # Claude Code conventions
├── docs/ # Project documentation (rendered via DocsAdapter)
│ ├── ARCHITECTURE.md
│ ├── getting-started.md
│ ├── adapters.md
│ ├── content-shape.md
│ └── mcp.md
├── src/
│ ├── adapters/
│ │ ├── interface.ts # InvertAdapter + InvertContent types
│ │ ├── json.ts # JSON file adapter
│ │ ├── markdown.ts # Markdown adapter (local + GitHub)
│ │ └── docs.ts # DocsAdapter — custom adapter example
│ ├── lib/
│ │ ├── config.ts # Site config + adapter registration
│ │ ├── content.ts # Content query helpers
│ │ └── utils.ts # URL helper for base-path-aware links
│ ├── layouts/
│ │ └── Base.astro # Base HTML layout
│ ├── pages/
│ │ ├── index.astro # Landing page
│ │ ├── [type]/
│ │ │ ├── index.astro # Content type listing
│ │ │ └── [slug].astro # Individual content page
│ │ └── 404.astro
│ └── components/
│ ├── ContentCard.astro # Listing card
│ └── ContentBody.astro # Content body renderer
├── mcp/
│ ├── server.ts # MCP server entry point
│ └── tools.ts # MCP tool implementations
├── webhooks/
│ └── rebuild.ts # Git push webhook listener scaffold
├── content/ # JSON content (MCP-writable)
├── markdown/ # Markdown content (human-managed)
└── public/ # Static assets
MIT