Skip to content

Storage Adapters

A storage adapter is the layer that persists CMS content. CaretCMS ships three and lets you write your own.

AdapterUse forPersistence
Filesystem DefaultLocal dev, single-server prod, git-tracked contentJSON files on disk
In-memoryTests, ephemeral previewsProcess memory (lost on restart)
Cloudflare KVCloudflare Workers, edgeCloudflare KV namespace
CustomPostgres, Redis, S3, anything elseYou decide
SituationPick
Local devFilesystem
Single-server prod (Node, Fly, Railway, Render, VPS)Filesystem with persistent volume
Multi-instance NodeCustom (Postgres / Redis)
Vercel / LambdaCustom (DB-backed) — no writable filesystem
Cloudflare WorkersCloudflare KV + R2
TestsIn-memory

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:

astro.config.mjs
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.

bumpRevision(collection, id) must atomically increment and return the new value. The mutation engine relies on this for optimistic locking.

ImplementationAtomicity strategy
FilesystemSerialized in-process queue (single process only)
KVSingle-key writes, last-write-wins (acceptable in practice for content)
PostgresUPDATE ... RETURNING in a single statement
RedisINCR

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.