Turn flat files in Git into typed, relational content for your TypeScript app. GraphQL and codegen are a common read interface for that content graphβnot the only surface you can build; see docs/positioning.md.
Flatbread is a Git-native relational flat-file content layer for TypeScript apps. Your repo and filesystem are the source of truth; plugins (sources, transformers, and resolvers) extend how content is loaded and shaped.
Who it's for: Teams shipping TypeScript sites, internal tools, and starters who want versioned, reviewable content and relationships between entriesβwithout standing up a CMS database or giving up ownership of where content lives.
Non-goals:
- Not a hosted CMS, dashboard, or authoring UI: Flatbread is a library and local workflow, not a full content-management product you log into.
- Not a general-purpose GraphQL platform or a substitute for a general-purpose database (transactions, granular access control, and high-scale multi-writer workloads are out of scope).
- Reliable live reload of content while the dev server runs is not a supported pillar yet; expect to restart to pick up file changes.
GraphQL: In the default toolkit, GraphQL is a common read interface for the content graphβschema generation and codegen are how many apps reach the data, not the definition of the product. More detail: docs/positioning.md.
Glossary: Quick definitions for collection, relation, ID, cardinality, validation, query interface, and how the generated GraphQL schema / operation types map to those terms (GraphQL as one read path, not the whole product)βsee docs/glossary.md.
Local dev loop: Codegen watch, schema rebuild, content reload, and framework restart boundaries are documented in docs/local-dev-loop.md.
Portability: Stable JSON snapshot export is available as a core API and documented in docs/json-export.md.
Ownership and exit: Raw files, Git history, JSON/CSV exports, GraphQL introspection, and generated TypeScript all fit one portability story in docs/data-ownership.md.
Roadmap: Current keep/kill/iterate decisions from validation work live in docs/roadmap.md.
For contributing to this monorepo, use Node 20.19+ with pnpm 10.33.x. Runtime support for published packages is tracked by each package's own metadata.
Born out of a desire to Gridsome (or Gatsby) anything, this project harnesses a plugin architecture to be easily customizable to fit your use cases.
π§ This project is experimental; the API may change before v1.0.
This repoβs canonical first success path is the Next.js example (examples/nextjs). It reads shared markdown under examples/content (mounted in that app as content/ via symlink). Commands below are exact for that layout.
- Collections (
Post,Author) map to folders of files; see the glossary. - Relations: posts declare
authors:in frontmatter as a list of author ids; Flatbread resolves them throughrefsin config (same idea as joins, over filesβnot a remote database). - Tags: in the bundled example, each post exposes
tagsas a YAML string list in frontmatter. That becomes a[String]field onPostin the generated schema. That is facet-style metadata repeated per postβnot the same machinery asrefsto another collection. If you need normalized tag records shared across posts, model aTagcollection and wirerefsyourself (advanced).
Illustrative frontmatter:
---
id: your-post-id
title: Example
authors:
- author-id-one
tags:
- typescript
- content-graph
---Markdown below the closing --- is the post body.
From the repo root, the markdown that backs the relational story lives here:
examples/content/markdown/posts/ # Post collection (incl. example-post.md, β¦)
examples/content/markdown/authors/ # Author collection
The Next example points flatbread.config.js at content/markdown/... relative to examples/nextjs, where content is the symlink to ../content.
Backing files for posts, authors, and tags (this example):
| What | Where it lives | Glossary terms |
|---|---|---|
| Posts | examples/content/markdown/posts/*.md β one record per file |
Collection, Record |
| Authors | examples/content/markdown/authors/*.md β one record per file |
Same; IDs in frontmatter wire relations |
| Tags | The tags: YAML list in each postβs frontmatter (facet metadata on that Post). There is no markdown/tags/ directory here. |
Tag (facet) vs Tag collection |
The table below ties the Git-native model to the default GraphQL read layer without implying GraphQL is the productβs whole identityβGraphQL is one query interface; files and config remain the source of truth.
| Layer | You see⦠| Glossary |
|---|---|---|
| Files | authors: ids in a post file match id: in author files; tags: is a string list on the post |
Relation, ID, Tag (facet) vs Tag collection |
flatbread.config.js |
content entries with collection: 'Post' | 'Author' and refs: { authors: 'Author' } |
Collection, Relation |
| Generated GraphQL schema + codegen TS | allPosts { tags authors { id name } } β refs resolve to Author objects; tags stays a scalar list on Post |
Generated schema and operation types (GraphQL), Cardinality |
Illustrative query result (same relation model as examples/content/markdown/posts/example-post.md: authors 2a3e / 40s3, tags from frontmatter). Values are from that file and its resolved authors; the shape matches the GetPostsAuthorsAndTags operation in Β§3 after you include tags and authors in your .graphql document (see also queries/posts.graphql, which you can extend the same way):
{
"allPosts": [
{
"id": "sdfsdf-23423-sdfsd-23444-dfghf",
"title": "The Art of Measuring Cats in Fruit Units",
"tags": ["cats", "measurements", "fruit-science", "important-research"],
"authors": [
{ "id": "2a3e", "name": "Tony" },
{ "id": "40s3", "name": "Eva" }
]
}
]
}Add tags (and any other fields) to your .graphql documents and rerun codegen so operations and generated/graphql.ts stay aligned with the filesβsnippets in docs are illustrative until your checked-in queries match.
Prerequisites: Node 20.19+, pnpm 10.33.x (see CONTRIBUTING.md).
pnpm install
pnpm build
cd examples/nextjs
pnpm exec flatbread codegen --verboseThat writes generated/graphql.ts: TypeScript types and typed document nodes for your .graphql operations (configure globs under codegen.documents in flatbread.config.js).
Add a .graphql file (see queries/posts.graphql in the example), then rerun pnpm exec flatbread codegen --verbose so the operation reflects tags, authors, etc. Illustrative operation you can paste into queries/:
query GetPostsAuthorsAndTags {
allPosts(limit: 5) {
id
title
tags
authors {
id
name
}
}
allAuthors {
id
name
}
}After codegen, your app imports types from ./generated/graphql. The result shape of that operation is typed (for example GetPostsAuthorsAndTagsQuery)βrelations resolve to Author objects while tags stay a string array on Post, matching the file metadataβthe same row as the illustrative JSON under Traceability.
The generated file also exposes a prototype TypeScript read API derived from the configured content model. In the Next.js example, examples/nextjs/lib/read.ts wires createFlatbreadReadApi() to the existing GraphQL fetcher and reads posts, authors, and tags with a generated default selectionβno hand-written GraphQL document at the call site.
Flatbread starts with Git-native relational content for TypeScript apps: flat files define records, frontmatter fields, ids, and refs; flatbread.config.js tells Flatbread how those files become typed collections. GraphQL is one interface over that typed model, and the generated TypeScript read API is another app-facing surface generated from the same model.
Use GraphQL operations when your app needs explicit query documents, custom selections, Apollo or other GraphQL clients, persisted operations, or direct access to the GraphQL endpoint. Add .graphql documents, include fields like tags and authors, and rerun codegen so operation types such as GetPostsAuthorsAndTagsQuery match the posts/authors/tags graph.
Use the prototype generated TypeScript read API when your app wants collection-shaped helpers for common reads from the configured Flatbread model, especially simple app reads such as posts, authors, tags, and resolved relations without writing GraphQL at each call site. The generated helpers currently execute through the GraphQL layer and still offer an experimental selection-string escape hatch, so both paths expose the same typed content graph backed by the same flat files while GraphQL remains the stable low-level interface.
Default filesystem + markdown wiring uses the bundled source-filesystem and transformer-markdown plugins (flatbread re-exports them).
The exampleβs production config loads extra collections for tests; the core onboarding shape is:
import { defineConfig, transformerMarkdown, sourceFilesystem } from 'flatbread';
export default defineConfig({
source: sourceFilesystem(),
transformer: transformerMarkdown({
markdown: { gfm: true, externalLinks: true },
}),
content: [
{
path: 'content/markdown/posts',
collection: 'Post',
refs: { authors: 'Author' },
},
{
path: 'content/markdown/authors',
collection: 'Author',
refs: { friend: 'Author' },
},
],
});Flatbread builds a content graph from files. In the default toolchain, GraphQL is one read interface: schema + resolver shape over that graphβnot βFlatbread is a GraphQL CMS.β
Wire your framework so the CLI wraps dev/build (flatbread start passes through your command after --). There is no flatbread dev subcommand.
// package.json scripts (adapt the part after `--` to your framework)
{
"scripts": {
"dev": "flatbread start -- next dev --turbopack",
"build": "flatbread start -- next build"
}
}In the Next example from examples/nextjs, pnpm dev enables HTTPS locally and pairs Next with Flatbread. The GraphQL HTTP endpoint defaults to http://localhost:5057/graphql; the Next app is on 3000. pnpm run dev here is distinct from next start alone (production Next without Flatbread unless you arrange serving yourself).
pnpm devIf the server starts cleanly, Flatbread prints the graphql URL. Opening it launches Apollo Studio against the generated schemaβyou can iterate on queries there, then freeze them into .graphql files and rerun flatbread codegen.
Live reload of markdown while the process runs is not reliable yetβrestart dev after content changes.
Outside this monorepo:
pnpm add flatbread@latestScaffold flatbread.config.js:
pnpm exec flatbread initPoint content entries at your posts/ and authors/ folders, reuse the relational ideas above, and add codegen in config when you want generated/graphql.ts. Browse packages for plugins and resolver helpers.
More detail on the bundled example (scripts, codegen watch, troubleshooting): examples/nextjs/README.md.
When GraphQL is your read interface, list fields use the following arguments in order of application.
Each collection in the GraphQL schema can be passed a filter argument to constrain your results, sifting for only what you want. Any leaf field should be able to be used in a filter.
The syntax for filter is based on a subset of MongoDB's query syntax.
A filter is composed of a nested object with a shape that matches the path to the value you want to compare on every entry in the given collection. The deepest nested level that does not have a JSON object as its value will be used to build the comparison where the key is the comparison operation and value is the value to compare every entry against.
filter = { postMeta: { rating: { gt: 80 } } };
entries = [
{ id: 1, title: 'My pretzel collection', postMeta: { rating: 97 } },
{ id: 2, title: 'Debugging the simulation', postMeta: { rating: 20 } },
{
id: 3,
title: 'Liquid Proust is a great tea vendor btw',
postMeta: { rating: 99 },
},
{ id: 4, title: 'Sitting in a chair', postMeta: { rating: 74 } },
];The above filter would return entries with a rating greater than 80:
result = [
{ id: 1, title: 'My pretzel collection', postMeta: { rating: 97 } },
{
id: 3,
title: 'Liquid Proust is a great tea vendor btw',
postMeta: { rating: 99 },
},
];eq- equal- This is like
filterValue === resultValuein JavaScript
- This is like
ne- not equal- This is like
filterValue !== resultValuein JavaScript
- This is like
in- This is like
filterValue.includes(resultValue)in JavaScript - Can only be passed an array of values which pass strict comparison
- This is like
nin- This is like
!filterValue.includes(resultValue)in JavaScript - Can only be passed an array of values which pass strict comparison
- This is like
includes- This is like
resultValue.includes(filterValue)in JavaScript - Can only be passed a single value which passes strict comparison
- This is like
excludes- This is like
!resultValue.includes(filterValue)in JavaScript - Can only be passed a single value which passes strict comparison
- This is like
lt,lte,gt,gte- This is like
<,<=,>,>=respectively - Can only be used with numbers, strings, and booleans
- This is like
exists- This is like
filterValue ? resultValue != undefined : resultValue == undefined - Accepts
trueorfalseas a value to compare against (filterValue) - For checking against a property that could be both
nullorundefined
- This is like
strictlyExists- This is like
filterValue ? resultValue !== undefined : resultValue === undefined - Accepts
trueorfalseas a value to compare against (filterValue) - Checking against a property for
undefined
- This is like
regex- This is like new RegExp(filterValue).test(resultValue) in JavaScript
wildcard
Caveats:
- Currently cannot infer date strings and then compare
Datetypes in filters- should work if you dynamically pass in a
Dateobject from your client, though not extensively tested - if you wanna take a shot at that, start a PR for adding arg typeOf checks and subsequent unique comparator functions π₯ͺ
- should work if you dynamically pass in a
You can union multiple filters together by adding peer objects within your filter object to point to multiple paths.
Using the entries from the previous example, let's combine multiple filters.
query FilteredPosts {
allPosts(
filter: { title: { wildcard: "*tion" }, postMeta: { rating: { gt: 80 } } }
) {
title
}
}Results in:
result = [{ title: 'My pretzel collection' }];Sorts by the given field. Accepts a root-level field name. Defaults to not sortin' at all.
The direction of sorting. Accepts ASC or DESC. Defaults to ASC.
Skips the specified number of entries. Accepts an integer.
Limits the number of returned entries to the specified amount. Accepts an integer.
Follow Quickstart (posts, authors, and tags) for the relational model, codegen, and typed results. For framework wiring and scripts, use examples/nextjs or other examples (for example SvelteKit).
Field overrides allow you to define custom GraphQL types or resolvers on top of fields in your content. For example, you could optimize images, encapsulate an endpoint, and more!
{
content: {
...
overrides: [
{
// using the field name
field: 'name'
// the resulting type is string
// this can be a custom gql type
type: 'String',
// capitalize the name
resolve: name => capitalize(name)
},
]
}
}-
basic nested objects
nested.object -
a basic array (will map array values)
an.array[] -
a nested object inside an array (will also map array)
an.array[]with.object
for more information in Overrides, they adhere to the GraphQLFieldConfig outlined here https://graphql-compose.github.io/docs/basics/what-is-resolver.html
Accepts a function which takes in field names and transforms them for the GraphQL schema generation -- this is used internally to remove spaces but can be used for other global transforms as well
{
...
// replace all spaces in field names with an underscore
fieldNameTransform: (fieldName) => fieldName.replace(/\s/g, '_')
...
}See CONTRIBUTING.md for the release workflow (bumping versions and publishing).
