ScribekitDocs

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.

7 min readUpdated 11 August 2026
Build a blog from scratch

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:

  • params is 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 uses getPostRefs() instead, which returns { slug, lang } pairs.)
  • dynamicParams = false means a slug you never prerendered 404s at the router, before the component runs. That is why no notFound() 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.

Was this page helpful?