Skip to content
qino

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 zod

From 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.

qino/posts.ts
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 in package.json and 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 zod
pnpm qino build