folio-db-next
v0.2.3
Published
Document-centric data persistence for Next.js apps, backed by markdown files in Vercel Blob.
Readme
folio-db-next
Document-centric data persistence for Next.js apps, backed by markdown files in Vercel Blob.
Every entity is a markdown file with YAML frontmatter. Every collection is a directory. The blob store is the database. See the root folio-design-doc.md for rationale and non-goals.
Install
pnpm add folio-db-next
# optional — only needed for the blob adapter
pnpm add @vercel/blobQuick start
import { createFolio } from 'folio-db-next';
import { MemoryAdapter } from 'folio-db-next/adapters/memory';
import { z } from 'zod';
const folio = createFolio({ adapter: new MemoryAdapter() });
const customers = folio.volume('customers', {
schema: z.object({
title: z.string(),
status: z.enum(['active', 'churned', 'prospect']),
tags: z.array(z.string()).default([]),
}),
});
await customers.set('acme-corp', {
frontmatter: { title: 'Acme Corp', status: 'active', tags: ['fintech'] },
body: '## Notes\n\nKey account. Renewal Q2 2026.',
});
const page = await customers.get('acme-corp');
const all = await customers.list();
const active = all.filter((p) => p.frontmatter.status === 'active');
const hits = await customers.search('renewal');Volume API
All methods live on Volume<T>:
| Method | Purpose |
|---|---|
| get(slug) | Fetch a single page, or null |
| set(slug, { frontmatter, body }, opts?) | Create or replace. opts.ifMatch for optimistic locking |
| patch(slug, { frontmatter?, body? }, opts?) | Partial update; missing fields preserved. Uses current etag unless ifMatch supplied |
| delete(slug) | Unconditional idempotent delete — no ifMatch; see issue #32 |
| list() | All pages in the volume, with bodies. Filter, sort, and paginate in the caller — folio deliberately doesn't ship a query DSL |
| list({ fields: 'frontmatter', limit?, offset?, orderBy?, order? }) | Frontmatter-only list view. Skips bodies; uses the runtime cache if configured. See "List cache" below |
| count() | Number of pages in the volume (metadata-only; no bodies fetched) |
| stats() | { count, bytesApprox, lastUpdated } from adapter listKeys |
| search(query, opts?) | Orama-backed full-text search across frontmatter + body |
| reindex() | Rebuild the search index from current pages |
Writes throw ConflictError when ifMatch doesn't match. Reads on missing slugs return null; patch throws NotFoundError.
Adapters
A StorageAdapter is anything implementing get / put / delete / list. Three are shipped:
folio-db-next/adapters/memory— in-process, for tests and examples.folio-db-next/adapters/blob— Vercel Blob. Requires the@vercel/blobpeer dep.folio-db-next/adapters/http— talks to afolio-serverinstance over HTTP.
Custom adapters should pass the conformance suite exercised by adapters/memory.test.ts.
List cache
volume.list({ fields: 'frontmatter' }) returns { slug, frontmatter, etag, updatedAt } for every page — bodies are skipped, which is the hot path for dashboards and index views. On a cold read it does listKeys + parallel get + parse; subsequent reads can be served from a pluggable ListCache.
The cache is never the source of truth. Writers invalidate it on every set/patch/delete/setIfAbsent. If the cache is unavailable, list views degrade to the uncached path — slower, never wrong.
Folio ships three implementations:
NoopListCache(default; always misses, no-op invalidate).MemoryListCache(in-process, optional TTL, good for dev and single-process deployments).RuntimeListCache(Vercel Runtime Cache; tag-based invalidation, shared across isolates).
import { createFolio } from 'folio-db-next';
import { RuntimeListCache } from 'folio-db-next/caches/runtime';
const folio = createFolio({ adapter });
const posts = folio.volume('posts', {
listCache: new RuntimeListCache({ ttlSeconds: 300 }),
});
const summary = await posts.list({
fields: 'frontmatter',
orderBy: 'updatedAt',
order: 'desc',
limit: 20,
});RuntimeListCache dynamically imports @vercel/functions — install it alongside folio-db-next in environments where it's available. Outside a Vercel Function the cache degrades to "always miss", which is correct but unaccelerated.
Rendering MDX
Folio stores raw markdown — the SDK deliberately doesn't parse the body. That leaves you free to render it however you like, including with MDX and your own React components. There's nothing to configure in folio itself; it's a pattern in the host app.
The repo ships a runnable example at
examples/mdx-blog — a Next.js 16 blog that seeds a
posts volume into the memory adapter and renders each body with
next-mdx-remote/rsc and a small Callout / Metric / MetricGrid
component registry. Start there for the full pattern, including
generateStaticParams, the component map, and sample posts that mix plain
markdown with MDX tags.
A few things to keep in mind when going down this path:
- Frontmatter stays structured. Use frontmatter for data (titles, tags,
dates) and Volume
query/searchfor retrieval. MDX is for presentation flourish in the body — callouts, embeds, charts — not a back door for data. - Search indexes raw body.
posts.search(...)tokenises the markdown string as-is, so component tags like<Callout>end up in the index. Keep the author-facing text as children, not attributes, so it remains searchable. - Portability tradeoff. A
.mdfile with JSX tags needs your component registry to render correctly elsewhere. Weigh that against the editorial benefit before adopting MDX broadly. - The Desk editor is markdown-aware, not MDX-aware. Pages with embedded
components still round-trip through
folio-deskas text, but the editor won't validate or preview them.
Development
pnpm --filter folio-db-next build
pnpm --filter folio-db-next test
pnpm --filter folio-db-next typecheckLicense
MIT.
