Folio
Folio is a document-centric data layer: every entity is a markdown file with YAML frontmatter, every collection is a Volume, and persistence goes through a pluggable StorageAdapter. The SDK is folio-db-next (package dir packages/folio-next). Schemas are validated with zod; full-text search is built in via Orama.
When to reach for Folio vs something else
- Reach for it when the data is naturally document-shaped (posts, recipes, configs, profiles), small-to-medium scale, and you want the content to be human-readable markdown on disk or in Blob.
- Reach for something else (Postgres/Neon, Upstash, etc.) when you need relational joins, high-throughput transactions, or strict multi-statement consistency. Folio gives you per-key CAS via ETags — not multi-key transactions.
Challenge the user if they reach for Folio for a workload it will handle badly (high write contention on a single key, cross-entity transactions, millions of rows).
Core API shape
import { createFolio } from 'folio-db-next';
import { z } from 'zod';
const folio = createFolio({ adapter });
const posts = folio.volume('posts', {
schema: z.object({
title: z.string(),
publishedAt: z.coerce.date(),
tags: z.array(z.string()).default([]),
}),
});
await posts.set('hello-world', {
frontmatter: { title: 'Hello', publishedAt: new Date(), tags: ['intro'] },
body: '# Hello\n\nFirst post.',
});
const page = await posts.get('hello-world'); // Page<T> | null
const recent = await posts.list({ fields: 'frontmatter', orderBy: 'updatedAt', order: 'desc', limit: 20 });
const hits = await posts.search('first post');
await posts.patch('hello-world', { frontmatter: { tags: ['intro', 'meta'] } });
await posts.delete('hello-world');
Key rules to internalise before writing code:
- Slugs: match
/^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/ — lowercase, may contain nested segments separated by /. No .., no backslashes.
- Volume names:
/^[a-z0-9][a-z0-9_-]*$/.
- Concurrency: every write supports
{ ifMatch: etag }. patch does its own optimistic retry loop (3 attempts with jittered backoff) unless you pass an explicit ifMatch. setIfAbsent(slug, input) is the atomic create primitive.
- list shapes:
list() returns full Page<T>[]; list({ fields: 'frontmatter', ... }) returns FrontmatterEntry<T>[] and is the one you want for index pages — it can be backed by a listCache.
- Search: Orama index, persisted to the adapter. If writes race the index, Folio self-heals via
reindex() and emits an event; never throws after a successful page write.
- Assets: binary attachments via
putAsset/getAsset/listAssets/deleteAsset. Stored base64-wrapped under volumes/{name}/{slug}/_assets/{assetName}. Cleared by deleteAll().
- ESM: package is
"type": "module"; relative imports inside the repo use .js extensions so tsc output resolves at runtime. Keep that convention when adding files.
Picking an adapter
| Situation |
Adapter |
Read the reference |
| Production on Vercel, shared across instances |
blob |
references/blob.md |
| Local dev, CLI tools, tests against a real filesystem |
fs |
references/fs.md |
| Unit tests, ephemeral scratch state |
memory |
(inline — createMemoryAdapter(), no config) |
Talking to a remote folio-db-server |
http |
(inline — createHttpAdapter({ baseUrl, token })) |
If the user is mixing environments (blob in prod, fs in dev), pick the adapter per-environment inside createFolio's setup code — don't try to swap at call sites.
Writing code that the conformance suite still respects
Every adapter must pass packages/folio-next/src/adapters/conformance.ts. If you're implementing a new adapter or changing an existing one:
- Run that suite (
pnpm --filter folio-db-next test and check the adapter-specific tests).
- Never bypass
StorageAdapter from the desk, server, or CLI — all persistence flows through it so the conformance tests stay meaningful.
delete is unconditional and idempotent by contract — do not add ifMatch.
Repo conventions (when working inside mcclowes/folio)
- pnpm workspace; directories are short (
folio-next, folio-cli, folio-server, folio-desk) but published names are folio-db-*. The desk is private and never published.
- Dependency direction:
folio-desk → folio-db-next; folio-db-server → folio-db-next; folio-db-cli → folio-db-next. Never reverse.
- Tests co-located with implementation (Vitest). TDD where practical.
- SCSS modules in the desk — not Tailwind.
- Sentence case in copy and commit messages.
- Don't add backwards-compat shims for pre-0.1 API; no external consumers yet.
- Don't hand-edit
packages/*/dist — generated.
- Before committing, run
pnpm test and pnpm typecheck for the packages you touched.
Error types you'll actually see
NotFoundError — patch on a missing slug; never thrown from get (returns null instead).
ConflictError — ifMatch / ifNoneMatch mismatch, or patch retry exhaustion.
InvalidSlugError / InvalidVolumeNameError / InvalidAssetNameError — always surface these as user input validation failures, not 500s.
FolioError — base class; other errors (e.g. malformed asset envelope) extend it.
Observability
volume.health() returns { adapter: 'ok'|'degraded', indexStale, listCacheAvailable, lastError? }. Pass onEvent into volume options to stream structured events (index_update_failed, write_conflict, list_cache_hit/miss, retry_exhausted, index_rebuild, list_cache_invalidate_failed). Observability hooks that throw are swallowed — writes never fail because a hook threw.
Next.js integration patterns
- Server Components / Server Actions: call
folio.volume(...).get/list/search directly. Cache with Next.js 16 Cache Components (use cache + cacheTag) keyed on the volume + slug. Invalidate with updateTag after writes.
- Route handlers: fine for mutations; use
ifMatch to reject stale PUTs from the client.
- Don't import
folio-db-next into 'use client' components — it's a server-only data layer.
- The dashboard package (
folio-desk) is a reference implementation showing the wiring — see lib/folio.ts and lib/volumes.ts.
Source: mcclowes/broadsheet — distributed by TomeVault.
1---2name: folio3description: Use Folio (folio-db-next) to persist data in Next.js apps as markdown files with YAML frontmatter, backed by a pluggable StorageAdapter (Vercel Blob, filesystem, HTTP, memory). Use whenever the user wants document-centric storage, a lightweight CMS-like layer, markdown-driven content, or asks about `createFolio`, `Volume`, "folio-db-*", "folio-next", or "the Desk". Also use when the user wants a git-friendly data layer, an alternative to a SQL/Mongo database for small-to-medium structured content, or needs to wire Vercel Blob into a Next.js app for content storage. Use when this capability is needed.4---56# Folio78Folio is a document-centric data layer: every entity is a markdown file with YAML frontmatter, every collection is a `Volume`, and persistence goes through a pluggable `StorageAdapter`. The SDK is `folio-db-next` (package dir `packages/folio-next`). Schemas are validated with `zod`; full-text search is built in via Orama.910## When to reach for Folio vs something else1112- **Reach for it** when the data is naturally document-shaped (posts, recipes, configs, profiles), small-to-medium scale, and you want the content to be human-readable markdown on disk or in Blob.13- **Reach for something else** (Postgres/Neon, Upstash, etc.) when you need relational joins, high-throughput transactions, or strict multi-statement consistency. Folio gives you per-key CAS via ETags — not multi-key transactions.1415Challenge the user if they reach for Folio for a workload it will handle badly (high write contention on a single key, cross-entity transactions, millions of rows).1617## Core API shape1819```ts20import { createFolio } from 'folio-db-next';21import { z } from 'zod';2223const folio = createFolio({ adapter });2425const posts = folio.volume('posts', {26 schema: z.object({27 title: z.string(),28 publishedAt: z.coerce.date(),29 tags: z.array(z.string()).default([]),30 }),31});3233await posts.set('hello-world', {34 frontmatter: { title: 'Hello', publishedAt: new Date(), tags: ['intro'] },35 body: '# Hello\n\nFirst post.',36});3738const page = await posts.get('hello-world'); // Page<T> | null39const recent = await posts.list({ fields: 'frontmatter', orderBy: 'updatedAt', order: 'desc', limit: 20 });40const hits = await posts.search('first post');41await posts.patch('hello-world', { frontmatter: { tags: ['intro', 'meta'] } });42await posts.delete('hello-world');43```4445Key rules to internalise before writing code:4647- **Slugs**: match `/^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/` — lowercase, may contain nested segments separated by `/`. No `..`, no backslashes.48- **Volume names**: `/^[a-z0-9][a-z0-9_-]*$/`.49- **Concurrency**: every write supports `{ ifMatch: etag }`. `patch` does its own optimistic retry loop (3 attempts with jittered backoff) unless you pass an explicit `ifMatch`. `setIfAbsent(slug, input)` is the atomic create primitive.50- **list shapes**: `list()` returns full `Page<T>[]`; `list({ fields: 'frontmatter', ... })` returns `FrontmatterEntry<T>[]` and is the one you want for index pages — it can be backed by a `listCache`.51- **Search**: Orama index, persisted to the adapter. If writes race the index, Folio self-heals via `reindex()` and emits an event; never throws after a successful page write.52- **Assets**: binary attachments via `putAsset/getAsset/listAssets/deleteAsset`. Stored base64-wrapped under `volumes/{name}/{slug}/_assets/{assetName}`. Cleared by `deleteAll()`.53- **ESM**: package is `"type": "module"`; relative imports inside the repo use `.js` extensions so `tsc` output resolves at runtime. Keep that convention when adding files.5455## Picking an adapter5657| Situation | Adapter | Read the reference |58| --- | --- | --- |59| Production on Vercel, shared across instances | `blob` | [references/blob.md](references/blob.md) |60| Local dev, CLI tools, tests against a real filesystem | `fs` | [references/fs.md](references/fs.md) |61| Unit tests, ephemeral scratch state | `memory` | (inline — `createMemoryAdapter()`, no config) |62| Talking to a remote `folio-db-server` | `http` | (inline — `createHttpAdapter({ baseUrl, token })`) |6364If the user is mixing environments (blob in prod, fs in dev), pick the adapter per-environment inside `createFolio`'s setup code — don't try to swap at call sites.6566## Writing code that the conformance suite still respects6768Every adapter must pass `packages/folio-next/src/adapters/conformance.ts`. If you're implementing a new adapter or changing an existing one:69701. Run that suite (`pnpm --filter folio-db-next test` and check the adapter-specific tests).712. Never bypass `StorageAdapter` from the desk, server, or CLI — all persistence flows through it so the conformance tests stay meaningful.723. `delete` is unconditional and idempotent by contract — do not add `ifMatch`.7374## Repo conventions (when working inside `mcclowes/folio`)7576- pnpm workspace; directories are short (`folio-next`, `folio-cli`, `folio-server`, `folio-desk`) but published names are `folio-db-*`. The desk is private and never published.77- Dependency direction: `folio-desk` → `folio-db-next`; `folio-db-server` → `folio-db-next`; `folio-db-cli` → `folio-db-next`. Never reverse.78- Tests co-located with implementation (Vitest). TDD where practical.79- SCSS modules in the desk — not Tailwind.80- Sentence case in copy and commit messages.81- Don't add backwards-compat shims for pre-0.1 API; no external consumers yet.82- Don't hand-edit `packages/*/dist` — generated.83- Before committing, run `pnpm test` and `pnpm typecheck` for the packages you touched.8485## Error types you'll actually see8687- `NotFoundError` — `patch` on a missing slug; never thrown from `get` (returns `null` instead).88- `ConflictError` — `ifMatch` / `ifNoneMatch` mismatch, or `patch` retry exhaustion.89- `InvalidSlugError` / `InvalidVolumeNameError` / `InvalidAssetNameError` — always surface these as user input validation failures, not 500s.90- `FolioError` — base class; other errors (e.g. malformed asset envelope) extend it.9192## Observability9394`volume.health()` returns `{ adapter: 'ok'|'degraded', indexStale, listCacheAvailable, lastError? }`. Pass `onEvent` into `volume` options to stream structured events (`index_update_failed`, `write_conflict`, `list_cache_hit/miss`, `retry_exhausted`, `index_rebuild`, `list_cache_invalidate_failed`). Observability hooks that throw are swallowed — writes never fail because a hook threw.9596## Next.js integration patterns9798- **Server Components / Server Actions**: call `folio.volume(...).get/list/search` directly. Cache with Next.js 16 Cache Components (`use cache` + `cacheTag`) keyed on the volume + slug. Invalidate with `updateTag` after writes.99- **Route handlers**: fine for mutations; use `ifMatch` to reject stale PUTs from the client.100- **Don't** import `folio-db-next` into `'use client'` components — it's a server-only data layer.101- The dashboard package (`folio-desk`) is a reference implementation showing the wiring — see `lib/folio.ts` and `lib/volumes.ts`.102103---104> Source: [mcclowes/broadsheet](https://github.com/mcclowes/broadsheet) — distributed by [TomeVault](https://tomevault.io).105<!-- tomevault:4.0:skill_md:2026-06-16 -->