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, plus supported prose in .md 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/. Field edits update YAML frontmatter. Editors can also update supported prose blocks on the rendered page and publish those changes back to .md files.
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: Studio and normal data-caret fields update frontmatter. Supported prose edits are kept in a private draft until Publish replaces the matching source range. Unedited body text stays byte-for-byte unchanged. 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.
See Markdown body editing for supported blocks, formatting, conflict handling, and Astro version notes.
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.