Storage
The pluggable ContentStore abstraction — local JSON files by default, GCS, S3, Azure, R2, Vercel Blob, or Netlify Blobs in production.
Every content type in this project — pages, blog posts, events, links, shop items, contact
submissions, uploaded media, and now media galleries — goes through one pluggable interface:
ContentStore (src/stores/types.ts). Seven backends implement it, all interchangeable
without touching any calling code.
The interface
export interface ContentStore {
read<T = unknown>(name: string): Promise<T | null>;
write(name: string, document: unknown): Promise<boolean>;
describe(): string;
writeMedia(filename: string, data: Uint8Array, contentType?: string): Promise<string | null>;
listMedia(prefix: string): Promise<{ name: string; url: string }[]>;
list(prefix: string): Promise<string[]>;
delete(name: string): Promise<boolean>;
}
Every method degrades gracefully — “not found” is never an exception. read/write/list/
delete operate on JSON documents (e.g. "blog/welcome.json"); writeMedia/listMedia
operate on real media files under an upload/ key namespace, returning a browser-viewable
URL. list() is hardcoded to .json files — it’s a content-document lister, not a general
file browser; that’s why the media gallery (see Features) needed
listMedia() added rather than reusing list().
The seven backends
| Backend | Selected by | Notes |
|---|---|---|
local |
Default — no cloud env var set | JSON files under data/content/; media under public/upload/, served directly by Astro |
gcs |
GRAVITY_SITE_BUCKET |
Google Cloud Storage. Credentials via Application Default Credentials |
s3 |
GRAVITY_S3_BUCKET |
AWS S3. Standard AWS credential chain (env vars or IAM role) |
azure |
GRAVITY_AZURE_CONTAINER |
Azure Blob Storage. Needs AZURE_STORAGE_CONNECTION_STRING too |
r2 |
GRAVITY_R2_BUCKET |
Cloudflare R2 — speaks the S3 API via @aws-sdk/client-s3. Needs CLOUDFLARE_ACCOUNT_ID + R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY, and GRAVITY_R2_PUBLIC_URL for writeMedia()/listMedia() to return real URLs (R2 has no client-derivable public URL) |
vercel_blob |
BLOB_READ_WRITE_TOKEN (auto-injected once a Blob store is linked) |
No bucket name to configure |
netlify_blob |
NETLIFY (auto-set by Netlify’s runtime) |
Media served back via src/pages/upload/[...path].ts, since Netlify Blobs has no public URL of its own |
GRAVITY_STORE_BACKEND=local|gcs|s3|azure|r2|vercel_blob|netlify_blob forces one explicitly.
getStore() (src/stores/index.ts) is a process-wide singleton — the backend choice is
resolved once per process, from whichever env vars are present, in that priority order.
GRAVITY_SITE_PREFIX scopes every read/write/list to a subdirectory (or key prefix), so one
bucket/root can be shared by multiple site instances without colliding. Leave it blank for a
single site.
Local disk in detail
LocalContentStore (src/stores/local.ts) keeps two separate directories:
data/content/(orGRAVITY_CONTENT_DIR) — JSON documents. Not web-servable.public/upload/(orGRAVITY_UPLOAD_DIR) — uploaded media, hash-prefixed filenames. Is web-servable, at/upload/<name>— this is Astro’s static directory, served for free, no route code needed.
This distinction matters for anything that needs to serve real files: data/content/ is
where the block editor’s own content lives (never served directly), while public/upload/
is where anything a site owner uploads through the admin UI or block image fields ends up,
and is genuinely reachable by a browser.
Media galleries: the one feature with a three-way local split
The media gallery (/gallery) is the one feature whose “local” mode isn’t a single thing —
see its own section in Features for the full local-disk vs. cloud-bucket
vs. Adhara breakdown. The short version: a local-disk gallery scans public/gallery/<slug>/
directly with fs.readdir, bypassing ContentStore entirely (it’s raw static files a site
owner drops in, not a JSON document); a cloud-bucket gallery uses the newer
listMedia() method instead, reusing the exact same pluggable backend already proven by
writeMedia().
Extending storage
Adding a genuinely new content type (not a new backend) gets a new src/services/*Store.ts
that calls getStore() — never a second, parallel storage mechanism. Every existing feature
follows this pattern: blogStore.ts, eventStore.ts, linkStore.ts, shopStore.ts,
galleryStore.ts, contactFormStore.ts, contactStore.ts.
If you need a genuinely new backend (an eighth cloud provider), implement the full
ContentStore interface in a new src/stores/<name>.ts, register it in the BUILDERS map
in src/stores/index.ts, and add its auto-detect env var to detectBackend()’s priority
list.