Headless flat-file CMS · v0.4
Markdown in.
TypeScript out.
Point qino at a content folder, hand it a schema, and you're laughing! You can now query your file system like an API, nothing to deploy.
pnpm add @qino/cms zodFrom content files to typed entries
01 — Markdown on disk
src/content/posts/
- hello-world.md
- typed-content.md
src/content/authors/
- camille-laurent.json
- jonas-weiss.json
02 — Qino
const postsCollection = qino.defineCollection({
directory: "/posts",
extension: ".md",
schema: PostSchema,
relations: {
author: authorsCollection,
},
views: (view) => ({
default: view({ resolveRelations: 1 }),
}),
})03 — In your app
await postsCollection.getEntries()[
{
title: "Hello world",
author: { name: "Camille Laurent" },
_meta: { slug: "hello-world" },
},
{
title: "Typed content",
author: { name: "Jonas Weiss" },
_meta: { slug: "typed-content" },
},
]Supported file types
Reads the files
you already have
Markdown
MDX
JSON
Three ways to model content
- Collection
Unordered list of of entries
A flat folder of similarly shaped entries such as
posts/*.md. Great for blog posts, events, team members, etc. Views filter, sort and paginate.- Read more about collections
- Tree
Hierarchy and order, first-class
Nested folders with anchor files and
_order.json. Great for docs, guides or a knowlegde base. Build a sidebar without validating a single body.- Read more about trees
- Item
One file with its own role
A single file, such as
pages/home.md, with a unique schema. Great for pages or navigation.- Read more about items
Relations
Qino makes your content a relational database.
Declare a relation and a frontmatter string becomes a typed foreign key. Any primitive can point at any other; the view decides how many hops resolve, up to 6.
export const postCollection = qino.defineCollection({
directory: "/posts",
extension: ".md",
schema: z.object({
title: z.string(),
author: z.string(),
categories: z.array(z.string()),
tags: z.array(z.string()),
}),
relations: {
author: authorCollection,
"categories[*]": categoryCollection,
"tags[*]": tagCollection,
},
views: (view) => ({
default: view({ resolveRelations: 1 }),
}),
})- posts.author
- authors
- posts.categories[*]
- categories
- posts.tags[*]
- tags
A broken relation should fail the build, not the page.
When a view resolves a relation, Qino checks that the linked entry exists. If it doesn't, the build fails, so broken links never ship.
One command between your content and a broken build.
The CLI runs before your framework does, so content errors surface in the terminal instead of at request time.
- qino build
Generate types
Checks every definition, validates every content file against its schema, and writes
qino/_generated/types.d.ts. Add it to your prebuild script inpackage.jsonand every deploy is gated on valid content.- qino check
Validate content only
Fast feedback in CI or on save. Relation fields are treated as ordinary strings and are not followed.
- qino lint
Validate definitions only
Checks that content and media folders exist, that no two primitives own overlapping paths, and that relations target the same instance.
Ready to try qino?
Node 22 or newer, a Standard Schema validator, and the folder of Markdown you already have.
pnpm add @qino/cms zodpnpm qino build