Build a blog from scratch
Wire a Next.js App Router app to a folder of MDX and end with a searchable post grid at /blog and a page per post.

By the end of this tutorial you have a working blog at /blog/: a searchable grid of post cards, a
page per post, and a category filter, all built from a folder of MDX files. You write two route
files, one shared module, and two posts, plus a one-line stylesheet import in your root layout.
Prerequisites. A Next.js App Router app on Next 15 or newer (the route files below use the
awaited params shape) on Node 20.9 or newer, and
@daanvandenbergh/scribekit plus next-mdx-remote installed - see
Installation. This tutorial uses a single language; every path below is
relative to your Next app root (the directory next dev runs from).
1. Create the Blog instance
One Blog instance is the whole backend. It reads your content directory at build time and every
route file derives its data from it.
Create app/blog/_blog.ts:
import { Blog } from "@daanvandenbergh/scribekit";
/** The single configured Blog instance. Every blog route reads from this. */
export const blog = new Blog({
contentDir: "./blog",
siteUrl: "https://example.com",
brandName: "Example",
});
contentDir resolves against process.cwd() - the directory next dev or next build runs from -
so "./blog" means a blog/ folder next to your package.json, not inside app/. The leading
underscore in _blog.ts marks the file as a colocated module rather than a route; it is a
convention, since only page, route, and layout files are ever routes.
siteUrl and brandName are both required before any SEO method runs. Set only one of them and
blog.overviewMetadata() throws with a message telling you to pass a site config. You use those
methods in the next two steps, so set both now.
trailingSlash is unset, so it defaults to true: every URL the instance builds ends in a slash.
Next's own trailingSlash defaults to false, so the two disagree until you say otherwise. Set it
in next.config.mjs:
export default { trailingSlash: true };
Skip it and nothing breaks in dev - Next just redirects - but the canonical, hreflang, sitemap, and
RSS URLs point at a form your build never emits, which on a static export is a feed and a sitemap of
404s. Prefer bare paths? Pass trailingSlash: false to new Blog({...}) and leave next.config
alone. Either way the two have to agree. (The RSS feed path itself is never slashed - it is a file.)
2. Add the overview route
BlogOverview renders the whole grid: the search box, the cards, and the infinite-scroll pager.
Create app/blog/page.tsx:
import { BlogOverview } from "@daanvandenbergh/scribekit/react";
import { blog } from "./_blog";
/** SEO metadata for the blog index. */
export function generateMetadata() {
return blog.overviewMetadata();
}
/** The blog overview at /blog. */
export default function BlogIndex() {
return <BlogOverview blog={blog} />;
}
blog is the only required prop. The component calls blog.getAllPosts() itself and paginates at
pageSize posts per batch, which defaults to 9.
3. Add the post route
Create app/blog/[slug]/page.tsx:
import { BlogPage } from "@daanvandenbergh/scribekit/react";
import { blog } from "../_blog";
/** Unknown slugs 404 at the router instead of rendering on demand. */
export const dynamicParams = false;
/** Prerender one page per post. */
export function generateStaticParams() {
return blog.getPostSlugs().map((slug) => ({ slug }));
}
/** Per-post SEO metadata. Falls back to a "not found" title. */
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
try {
return blog.postMetadata(blog.getPost(slug));
} catch {
return { title: "Post not found" };
}
}
/** A post at /blog/<slug>. */
export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
return <BlogPage blog={blog} slug={slug} />;
}
Three things in that file are load-bearing:
paramsis a Promise and must be awaited. This is the App Router's shape from Next 15 on.getPostSlugs()returns distinct slugs, which is exactly the shape a single-language[slug]route needs. (A multi-language blog usesgetPostRefs()instead, which returns{ slug, lang }pairs.)dynamicParams = falsemeans a slug you never prerendered 404s at the router, before the component runs. That is why nonotFound()guard is needed here.
4. Write your first post
The folder name is the slug. The file name is the language.
Create blog/hello-world/en.mdx:
---
title: "Hello world"
description: "The first post on this blog."
date: "2026-07-16"
author: "Your Name"
---
## Why this exists
The body starts at `##`. The `title` above renders the H1, so a second one would be a duplicate.
Tables, task lists, and strikethrough all work: `remark-gfm` is always on.
The file is en.mdx because the default locale resolves to en when you configure no locales
(it falls back to the locale option, which defaults to "en-GB", and takes its primary subtag).
title, description, and date are the fields worth setting on day one. readingTime is
computed from the body - never write it yourself.
5. Import the stylesheet
The components ship unstyled without it. Import it once, app-wide, in app/layout.tsx:
import "@daanvandenbergh/scribekit/styles.css";
Import it once and only once. It is marked as a side effect, so bundlers keep it.
6. See it render
Start your dev server and open http://localhost:3000/blog/.
You see a search box reading "Search posts…", and below it one card with the title "Hello world",
its description, its date, a "1 min read" chip, and a "Read more →" link. Click the card and you land
on /blog/hello-world/: a "← Blog" back link, the post title, a meta line carrying the author name
and "1 min read", your ## Why this exists heading, an "On this page" minimap on the right (it
appears because the body has a ##), and a "Written by / Your Name" bio closing the article.
Two of those come from choices in step 4: the author name and bio because the front-matter sets
author, and the minimap because there is a heading to list. Pass showSidebar={false} to drop the
minimap, or omit author to drop the bio.
If the grid says "No posts yet - check back soon.", the content directory is wrong. contentDir
resolves against the directory the app runs from, so confirm blog/hello-world/en.mdx sits beside
package.json, not inside app/.
7. Add a second post
One post proves the route. Two prove the grid.
Create blog/writing-with-mdx/en.mdx:
---
title: "Writing with MDX"
description: "How the front-matter maps to what renders on the card and the page."
date: "2026-07-15"
author: "Your Name"
---
## The folder is the URL
This post lives at `blog/writing-with-mdx/en.mdx`, so it is served at `/blog/writing-with-mdx/`.
Reload /blog/. Both cards are there, "Hello world" first: posts sort by date descending. Type
mdx into the search box and the grid narrows to one card as you type. The search runs entirely
in the browser, so there is no round trip.
8. Add categories and see the filter appear
categories is a YAML list, never a bare string. categories: "Guides" is silently dropped -
the field parses, the page renders, and the post simply has no categories forever.
Replace the front-matter block of blog/hello-world/en.mdx with this - do not append a second
one, because only the first --- block in a file is parsed, and a pasted second block becomes body
text while the categories are never read:
---
title: "Hello world"
description: "The first post on this blog."
date: "2026-07-16"
author: "Your Name"
categories:
- Announcements
---
Replace the front-matter of blog/writing-with-mdx/en.mdx the same way:
---
title: "Writing with MDX"
description: "How the front-matter maps to what renders on the card and the page."
date: "2026-07-15"
author: "Your Name"
categories:
- Guides
---
Reload /blog/. A filter row appears above the grid: "All", "Announcements", "Guides". Click
"Guides" and only the MDX post stays.
The filter renders only when there is more than one distinct category. Give both posts the same category and the row disappears again - which is the usual reason a first attempt at this step shows nothing.
What you have now
Two posts, a searchable and filterable card grid at /blog/, a page per post at /blog/<slug>/,
and canonical, OpenGraph, and Twitter metadata on both routes. Every new post is one more folder
under blog/; no route file changes again.
The one prop you set here is pageSize. The ones left at their defaults - linkComponent,
imgComponent, showSidebar, and the label overrides - are all listed in the
API reference. The concepts behind the file layout are in
Getting started.