Skip to content

Contentlayer

Replace contentlayer.config.ts, generated imports, and withContentlayer with qino definitions and getters.

View Markdown

Contentlayer and qino solve the same problem: turn a folder of Markdown into typed data. They get there differently.

Contentlayerqino
Configcontentlayer.config.tsqino/index.ts plus one file per definition
SchemaContentlayer field typesAny Standard Schema validator, such as Zod
OutputJSON cached in .contentlayer/generatedFiles read from disk when a getter runs
Readingimport { allPosts } from "contentlayer/generated"await postCollection.getEntries()
TypesGenerated from the configInferred from the schema, slugs generated
BodyCompiled to html or MDX codeRaw markdown string, rendered by your app
ReferencesPassed through as pathsResolved into entries by a view
Framework gluewithContentlayer in next.configqino build as a prebuild script

Your content files mostly stay where they are. The work is in the definitions and the call sites.

1. Swap the packages

Remove Contentlayer:

Terminal
npm uninstall contentlayer next-contentlayer

Then install qino and a validator. See Installation.

2. Undo the Next.js wiring

Unwrap the Next.js config:

next.config.ts
// Before
import { withContentlayer } from "next-contentlayer";
export default withContentlayer(nextConfig);

// After
export default nextConfig;

Remove the contentlayer/generated alias from tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "contentlayer/generated": ["./.contentlayer/generated"] // delete
    }
  }
}

Replace .contentlayer in .gitignore with qino's generated folder, then delete the .contentlayer folder:

.gitignore
qino/_generated/

3. Replace makeSource with initQino

makeSource points at the content directory and lists document types. initQino points at the content folder and the folder that serves media. Definitions are picked up from qino/ by the CLI, so there is no list to maintain.

contentlayer.config.ts
export default makeSource({
  contentDirPath: "content",
  documentTypes: [Post, Author, Settings],
});
qino/index.ts
import { initQino } from "@qino/cms";

export default initQino({
  contentFolder: "content",
  mediaFolder: "public",
});

See Project structure.

4. Pick a primitive per document type

Contentlayer has one defineDocumentType and uses filePathPattern and isSingleton to describe what it holds. qino has three primitives instead:

Contentlayer document typeqino
filePathPattern: "posts/*.md"Collection with directory: "posts"
filePathPattern: "docs/**/*.mdx"Tree with directory: "docs"
isSingleton: trueItem with file

A collection is flat: files in subfolders are ignored. If a ** pattern only exists to group files that don't form a hierarchy, flatten the folder into a collection. If the folders are the hierarchy, such as a docs sidebar, use a tree. Trees need an anchor file for each folder, so guides/ needs a sibling guides.mdx. See Content folder.

Two primitives can't own overlapping paths. A single catch-all pattern like **/*.md must be split into one definition per folder.

contentType maps to the file extension:

contentTypeqino
markdownextension: ".md"
mdxextension: ".mdx"
dataextension: ".json". YAML data files aren't supported yet, convert them to JSON

5. Translate fields into a schema

fields becomes a schema. The examples use Zod.

contentlayer.config.ts
export const Post = defineDocumentType(() => ({
  name: "Post",
  filePathPattern: "posts/*.md",
  contentType: "markdown",
  fields: {
    title: { type: "string", required: true },
    date: { type: "date", required: true },
    draft: { type: "boolean", default: false },
    tags: { type: "list", of: { type: "string" } },
    author: { type: "reference", of: Author },
  },
}));
qino/collections/posts.ts
import { z } from "zod";

import qino from "../";
import { authorCollection } from "./authors";

export const postCollection = qino.defineCollection({
  directory: "posts",
  extension: ".md",
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    draft: z.boolean().default(false),
    tags: z.array(z.string()).optional(),
    author: z.string().optional(),
  }),
  relations: { author: authorCollection },
});

Contentlayer fields are optional unless required: true. Zod fields are required unless .optional(). Flip them as you go.

Contentlayer fieldZod
stringz.string()
numberz.number()
booleanz.boolean()
datez.iso.date() or z.coerce.date(). See Dates
enum with optionsz.enum(["light", "dark"])
jsonz.unknown(), or a precise z.object()
list of stringz.array(z.string())
list of a nested typez.array(z.object({ ... }))
nestedz.object({ ... })
nested with several typesz.discriminatedUnion("type", [ ... ])
referencez.string() plus an entry in relations
markdown, mdxz.string(), rendered by your app
default: value.default(value)
description.describe("...")

defineNestedType has no equivalent. A nested type is a schema, so declare it once and reuse it:

qino/schemas/seo.ts
export const seoSchema = z.object({
  title: z.string(),
  image: z.string().optional(),
});

Dates

Contentlayer returns date fields as ISO strings, such as 2024-01-15T00:00:00.000Z. qino doesn't convert untagged YAML dates either, but it returns them as written, such as 2024-01-15. Pick one:

  • z.iso.date() validates the string and keeps it a string. Code that parses it with new Date(post.date) keeps working, but code that relied on the normalized time part may not.
  • z.coerce.date() turns it into a Date. This changes the output type, so update call sites: new Date(post.date) becomes post.date.

The examples on this page use z.coerce.date(). To get a Date without a schema transform, tag the value with !!timestamp. See Frontmatter.

6. Keep your references

Contentlayer stores a reference as a path relative to contentDirPath, with the extension. That is exactly a qino content path, so existing values work as is. If you converted YAML data files to JSON in step 4, update references to them too: qino requires the extension to match the target, so authors/jane.yaml becomes authors/jane.json.

content/posts/hello-world.md
author: "authors/jane.json"

The difference is what you get back. Contentlayer passes the string through and leaves the lookup to you. qino resolves it when a view asks for it:

qino/collections/posts.ts
views: (view) => ({
  default: view({ resolveRelations: 1 }),
}),

post.author is now the author entry, typed. Helpers like allAuthors.find((a) => a._raw.sourceFilePath == post.author) can go. See Relations.

7. Move computed fields

computedFields resolves extra values per document. qino has two places for derived data:

  • A schema transform runs once per file and sees the file's data. Use it to reshape or rename fields.
  • A view augment runs per view and sees the body, _meta, and resolved relations. It may be async. Use it for anything that needs more than the frontmatter.

The common url field moves to augment:

contentlayer.config.ts
computedFields: {
  url: {
    type: "string",
    resolve: (post) => `/${post._raw.flattenedPath}`,
  },
},
qino/collections/posts.ts
views: (view) => ({
  default: view({
    augment: (post) => ({ url: `/posts/${post._meta.slug}` }),
  }),
}),

For reading time or word count, set markdownStats: true and derive from _stats.wordCount. See Entry pipeline.

8. Map document properties

Every Contentlayer document carries _id, _raw, type, and body. Their qino counterparts:

Contentlayerqino
_raw.flattenedPath_meta.slug. Relative to the directory, not the content root: hello-world, not posts/hello-world
_raw.sourceFileName_meta.fileName
_raw.sourceFilePath_meta.filePath. Absolute, not relative
_id_meta.slug, unique within one definition. _id was the path from the content root, so prefix the directory when you need a global key
typeNone. The definition you called tells you the type
body.rawmarkdown
body.htmlNone. Render markdown yourself
body.code (MDX)None. Compile markdown with your MDX renderer

9. Render the body

Contentlayer compiles Markdown to HTML and MDX to a function body, and you render it with dangerouslySetInnerHTML or useMDXComponent. qino hands you the source and leaves rendering to your app.

Before
<div dangerouslySetInnerHTML={{ __html: post.body.html }} />
After
import Markdown from "react-markdown";

<Markdown>{post.markdown}</Markdown>;

For MDX, replace useMDXComponent(post.body.code) with a runtime compiler such as next-mdx-remote. Move your remark and rehype plugins from makeSource({ markdown, mdx }) to the renderer. The Next.js example uses react-markdown for .md and next-mdx-remote for .mdx.

10. Replace generated imports with getters

Contentlayer exports one array per document type. qino exposes async getters on each definition. They read files with Node.js APIs, so call them from server code: React Server Components, generateStaticParams, route handlers. See Runtime.

src/app/posts/page.tsx
// Before
import { allPosts } from "contentlayer/generated";

const posts = allPosts
  .filter((post) => !post.draft)
  .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

// After
import { postCollection } from "../../../qino/collections/posts";

const posts = await postCollection.getEntries();

Filtering and sorting move into the definition, so every page gets the same list:

qino/collections/posts.ts
views: (view) => ({
  default: view({
    filter: (post) => !post.draft,
    sort: (a, b) => b.date.getTime() - a.date.getTime(),
  }),
}),

Looking up one document by path becomes getEntry:

src/app/posts/[slug]/page.tsx
// Before
const post = allPosts.find(
  (post) => post._raw.flattenedPath == `posts/${params.slug}`,
);
if (!post) notFound();

// After
export const dynamicParams = false;

export async function generateStaticParams() {
  const posts = await postCollection.getEntries();
  return posts.map((post) => ({ slug: post._meta.slug }));
}

const { slug } = await params;
const post = await postCollection.getEntry(slug);

getEntry throws when the file is missing or its view filters it out, instead of returning undefined. With dynamicParams = false, Next.js answers unknown slugs with a 404 before getEntry runs, which replaces the notFound() check.

Slugs come from getEntries rather than getAllSlugs, because getAllSlugs ignores views and would list drafts the default view filters out.

A singleton becomes await settingsItem.getEntry().

11. Replace generated types

Contentlayer generates a type per document type. qino infers it from the definition:

src/lib/types.ts
// Before
import type { Post } from "contentlayer/generated";

// After
import type { Infer } from "@qino/cms";
import type { postCollection } from "../../qino/collections/posts";

export type Post = Infer<typeof postCollection>["output"];

output includes the default view's resolved relations and augmented fields. There is no allPosts type to replace; use Array<Post>. See Types.

12. Validate on build

Contentlayer builds content inside next dev and next build. qino has no build step for content: getters read files on every call, so an edited file shows on the next getter call. Under next dev, that means the next page load. In production, prerendered and cached pages don't run their getters on each request, so content changes need a rebuild or a revalidation. Drop useLiveReload: the browser no longer refreshes on its own when content changes, so reload the page.

Validation and slug types come from the CLI. Run it before the app builds:

package.json
{
  "scripts": {
    "prebuild": "qino build",
    "build": "next build"
  }
}

qino build validates every file, so a missing field fails the build, as with Contentlayer. It also writes slug unions, so getEntry autocompletes known slugs. See CLI.

Contentlayer bundled content into the generated JS. qino reads it from disk, so pages rendered on request need the content folder on the deployed server: list it in outputFileTracingIncludes. Fully prerendered sites can skip this. See Runtime.

Checklist

  • contentlayer and next-contentlayer uninstalled, withContentlayer and the tsconfig.json alias removed.
  • .contentlayer/ deleted and unignored, qino/_generated/ ignored.
  • One collection, tree, or item per document type, with no overlapping paths.
  • Optional fields marked .optional(), date fields validated or coerced, call sites updated.
  • Reference fields listed in relations, resolved by a view.
  • computedFields moved to transforms or augment.
  • Bodies rendered from markdown.
  • allX imports replaced by getEntries and getEntry, called from server code.
  • qino build runs as prebuild.