Storage Adapters
A storage adapter is the layer that persists CMS content. CaretCMS ships four and lets you write your own.
| Adapter | Use for | Persistence |
|---|---|---|
| Filesystem Default | Local dev, single-server prod, git-tracked content | JSON files on disk |
| Markdown Content collections | Editing existing Astro content collections in place | YAML frontmatter in .md / .mdx files |
| In-memory | Tests, ephemeral previews | Process memory (lost on restart) |
| Cloudflare KV | Cloudflare Workers, edge | Cloudflare KV namespace |
| Custom | Postgres, Redis, S3, anything else | You decide |
Picking an adapter
Section titled “Picking an adapter”| Situation | Pick |
|---|---|
| Local dev | Filesystem or Markdown (auto-detected) |
Site built on Astro content collections (src/content/*.md) | Markdown |
| Static CDN (static delivery) | Filesystem — .caret/data/ in repo or CI checkout |
| Single-server prod (Node, Fly, Railway, Render, VPS) | Filesystem with persistent volume |
| Multi-instance Node | Custom (Postgres / Redis) |
| Vercel / Lambda | Custom (DB-backed) — no writable filesystem |
| Cloudflare Workers | Cloudflare KV + R2 |
| Tests | In-memory |
The adapters
Section titled “The adapters”The default. Writes JSON files under .caret/data/ and metadata under .caretcms/.
Directory.caret/data/
Directorypages/
- home.json
- about.json
Directorysite/
- global.json
Directory.caretcms/
- revisions.json optimistic-locking counters
Directoryhistory/
Directorypages/
- home.json per-entry history (last 50)
Directorycollections/
- products.json dynamic collection metadata
No setup needed — caret() with no options uses it. The directories are created on first write. Customize roots:
import caret, { filesystemStorage } from '@caretcms/core';
caret({ storage: filesystemStorage({ dataRoot: 'src/content/cms', // git-track content in source metaRoot: 'src/content/cms/.meta', }),})When to use: local dev always. Production when you have one server (or shared NFS), don’t need horizontal scaling, and like content in git.
When not to use: serverless (Workers, Lambda — they don’t have writable disk), multi-instance deployments without shared storage, or anything edge-deployed.
For sites already built on Astro content collections. Instead of a separate .caret/data/ store, this adapter reads and writes the Markdown files you already have under src/content/ — edits land in each entry’s YAML frontmatter, in place.
Directorysrc/content/
Directoryblog/
- hello-world.md frontmatter = entry data
- second-post.mdx
Directoryauthors/
- jane.md
Directory.caretcms/ CMS state stays out of your content
- revisions.json
Directoryhistory/
- …
Directorycollections/
- …
One set of files powers both Astro’s native getCollection() / render() and CaretCMS editing — there’s no copy or sync step. Wire it explicitly:
import caret, { markdownStorage } from '@caretcms/core';
caret({ storage: markdownStorage({ contentRoot: 'src/content', // default }),})How edits are written: only the frontmatter is rewritten. The Markdown body below the closing --- (including MDX imports and JSX) is spliced through byte-for-byte — the body isn’t editable in v1. Revisions, history, and collection metadata live in the .caretcms/ sidecar, so your source files stay free of CMS bookkeeping.
When to use: any site whose content is already Markdown content collections, when you want to edit those entries’ fields — in Studio or inline — without moving them into a separate store. Pairs naturally with registered schemas derived from the same Zod schema your content.config.ts already uses.
Static delivery note: fields rendered through getCollection() at build time only refresh after rebuild. Pair with commit-on-publish + CI when using static delivery.
Holds everything in JS maps. Lost when the process restarts.
When to use: unit and integration tests. Anywhere you don’t want disk writes polluting state across runs.
Unlike the other adapters, in-memory isn’t wired through caret({ storage }) — there’s no provider entrypoint to reference. You construct InMemoryAdapter directly, the way the package’s own test suite does:
import { InMemoryAdapter } from '@caretcms/core';
const adapter = new InMemoryAdapter();adapter.preload('pages', [{ id: 'home', data: { headline: 'Hello' } }]);Install the Cloudflare package:
npm install @caretcms/cloudflareimport { defineConfig } from 'astro/config';import cloudflare from '@astrojs/cloudflare';import caret from '@caretcms/core';import { cloudflareStorage, r2Uploads } from '@caretcms/cloudflare';
export default defineConfig({ output: 'server', adapter: cloudflare(), integrations: [ caret({ storage: cloudflareStorage({ binding: 'CMS_KV' }), uploads: r2Uploads({ binding: 'CMS_R2' }), }), ],});Bindings come from wrangler.toml:
name = "my-site"compatibility_date = "2025-01-01"
[[kv_namespaces]]binding = "CMS_KV"id = "<your-kv-namespace-id>"
[[r2_buckets]]binding = "CMS_R2"bucket_name = "my-site-uploads"Storage layout in KV:
collections → ["pages", "site", "products"] (known-collections index)index::pages → ["home", "about"] (per-collection entry index)pages::home → entry JSONrev::pages::home → revision counterhistory::pages::home → revision history arraymeta::products → CollectionMetadata for dynamic collectionsWhen to use: any Cloudflare Workers deploy, edge-distributed sites, anything that scales horizontally.
When not to use: non-Cloudflare hosts. KV’s eventual consistency (60s replication) is fine for content but not for tightly-coupled state.
See Deployment → Cloudflare for the end-to-end setup.
Implement the StorageAdapter interface:
import type { StorageAdapter, EntryData, HistoryEntry, CollectionMetadata,} from '@caretcms/core';
export class PostgresAdapter implements StorageAdapter { // collection / entry reads async discoverCollections(): Promise<string[]> { /* ... */ } async isKnownCollection(collection: string): Promise<boolean> { /* ... */ } async getEntry(collection: string, id: string): Promise<EntryData | null> { /* ... */ } async listEntryIds(collection: string): Promise<string[]> { /* ... */ } async listEntries(collection: string): Promise<EntryData[]> { /* ... */ }
// entry mutations async writeEntry(collection: string, id: string, data: Record<string, unknown>): Promise<void> { /* ... */ } async deleteEntry(collection: string, id: string): Promise<void> { /* ... */ }
// optimistic locking async getRevision(collection: string, id: string): Promise<number> { /* ... */ } async bumpRevision(collection: string, id: string): Promise<number> { /* ... */ }
// history async getHistory(collection: string, id: string): Promise<HistoryEntry[]> { /* ... */ } async appendHistory(collection: string, id: string, entry: HistoryEntry): Promise<void> { /* ... */ }
// dynamic collection metadata async createCollection(metadata: CollectionMetadata): Promise<void> { /* ... */ } async deleteCollection(collection: string): Promise<void> { /* ... */ } async getCollectionMetadata(collection: string): Promise<CollectionMetadata | null> { /* ... */ } async listCollectionMetadata(): Promise<CollectionMetadata[]> { /* ... */ }
// optional — only for demo/sandbox mode (per-session write isolation) // async makeSessionOverlay(sessionId: string): Promise<StorageAdapter> { /* ... */ }}Reordering isn’t a separate adapter method — the mutation engine persists order by writing entries with an updated order field and bumping revisions, so you don’t implement a reorderEntries call.
Wire it via a provider reference:
import caret, { defineStorageProvider } from '@caretcms/core';
export default defineConfig({ integrations: [ caret({ storage: defineStorageProvider({ entrypoint: './src/cms/postgres-adapter.ts', exportName: 'postgresStorageProvider', options: { connectionString: process.env.DATABASE_URL }, }), }), ],});The integration imports your entrypoint at runtime, calls the named export, and uses whatever it returns. The export can be:
- A factory function — called with
optionsonce, returns an adapter - An adapter instance — used directly
Concurrency contract
Section titled “Concurrency contract”bumpRevision(collection, id) must atomically increment and return the new value. The mutation engine relies on this for optimistic locking.
| Implementation | Atomicity strategy |
|---|---|
| Filesystem | Serialized in-process queue (single process only) |
| KV | Single-key writes, last-write-wins (acceptable in practice for content) |
| Postgres | UPDATE ... RETURNING in a single statement |
| Redis | INCR |
Field validation
Section titled “Field validation”Field paths come from user input. The mutation engine already rejects prototype-pollution attempts (__proto__, constructor.prototype, etc.) before they reach your adapter. You don’t need to re-validate; just trust the inputs you receive in adapter methods.