npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@daanvandenbergh/scribekit

v1.1.3

Published

Drop-in, SEO-ready MDX blog and docs for Next.js App Router: server components for the post grid, articles, and a tabbed docs site, plus Claude Code skills that research and write them.

Readme

@daanvandenbergh/scribekit

scribekit - a drop-in MDX blog and docs site for Next.js

Drop a fast, SEO-ready blog and docs site into any Next.js app - and write both with Claude Code.

Built specifically for the App Router (React Server Components). One package, two surfaces:

  • Blog - the Blog class reads .mdx posts from a folder and builds their SEO metadata + JSON-LD. <BlogOverview> renders the /blog grid (fuzzy search, category filters, infinite scroll); <BlogPage> renders a post with a heading minimap, reading time, and similar posts.
  • Docs - the Docs class turns a folder of .mdx pages into a tabbed, grouped nav assembled from front-matter (no config file). <DocsNavbar> / <DocsTabs> / <DocsSidebar> / <DocsPage> / <DocsToc> / <DocsIndex> render a full-width docs app with a ⌘K command palette, breadcrumbs, prev/next, and a scroll-spy "On this page" minimap.
  • Claude Code skills - research, write, and rewrite posts and docs pages in your project's house style, and generate on-brand hero images.

You build the page chrome (navbar, footer, hero); scribekit renders the blog and docs body. Both surfaces share the same theming tokens, locales, and MDX pipeline.

Contents: Install · Claude Code skills · Demo · Blog · Docs · Multiple locales · Theming · MDX plugins · API reference · Notes


Install

npm install @daanvandenbergh/scribekit

Peer dependencies (you almost certainly already have the first three): next, react, react-dom, and next-mdx-remote (used to render MDX).

npm install next-mdx-remote

Import the stylesheet once (e.g. in your root app/layout.tsx):

import "@daanvandenbergh/scribekit/styles.css";

The Claude Code skills

The package ships four Claude Code skills. Symlink them into your project's .claude/skills so Claude Code picks them up:

mkdir -p .claude/skills
ln -s ../../node_modules/@daanvandenbergh/scribekit/skills/scribekit-blog .claude/skills/scribekit-blog
ln -s ../../node_modules/@daanvandenbergh/scribekit/skills/scribekit-docs .claude/skills/scribekit-docs
ln -s ../../node_modules/@daanvandenbergh/scribekit/skills/scribekit-hero .claude/skills/scribekit-hero
ln -s ../../node_modules/@daanvandenbergh/scribekit/skills/scribekit-docs-github-pages .claude/skills/scribekit-docs-github-pages
  • scribekit-blog - writes and rewrites blog posts following its research protocol, house style, and SEO checklist. Ask Claude Code to write or rewrite a post (rewrite --scan reports only). Each post's hero is generated by scribekit-hero, which the write flow calls automatically.
  • scribekit-docs - writes, rewrites, and reorganizes documentation pages, sourced from your own code so every option, default, and code sample is true. Ask it to write a page (it auto-proposes the highest-value gap), rewrite one against the current code, or reorganize the whole corpus - it redesigns the tab/group/order structure, fixes the front-matter that silently breaks the nav, and reports badly-named slugs without renaming any (so no URL ever changes). --scan reports only. It reuses scribekit-blog's house style and research protocol, so install scribekit-blog alongside it.
  • scribekit-hero - all hero-image generation, rendered on-brand from HTML/CSS (never an AI image generator): blog/docs page heroes, regenerate-all after a design change, gradient tuning, and a README banner in the same family. Self-contained - you can install it with or without the others.
  • scribekit-docs-github-pages - publishes your docs site (the whole Next.js app) to GitHub Pages as a static export. It learns the project, adds output: 'export' to your Next config, writes the deploy workflow, points siteUrl at the Pages origin, and handles what static export changes in the routing layer (middleware clean-URLs and renamed-slug redirects). See Deploy to GitHub Pages.

Demo

A complete, runnable reference app lives in demo/ - a minimal Next.js App Router site with a bilingual Blog at /blog and a tabbed Docs site at /docs (one Blog + one Docs instance, their route trees, and folders of MDX). Copy its layout into your own site, or run it from the repo root: npm install && npm run demo (builds the package, then serves the demo at http://localhost:3000).


Blog

Quick start (3 files)

1. Configure one Blog instance. Pass this one instance to the components - they derive the posts, the date locale, and the SEO JSON-LD from it (blog.site is public too).

// app/blog/_blog.ts
import { Blog } from "@daanvandenbergh/scribekit";

export const blog = new Blog({
    contentDir: "./blog", // where your <slug>/en.mdx post folders live (repo root ./blog)
    siteUrl: "https://example.com",
    brandName: "Example",
});

2. The /blog overview page.

// app/blog/page.tsx
import { BlogOverview } from "@daanvandenbergh/scribekit/react";
import { blog } from "./_blog";

export function generateMetadata() {
    return blog.overviewMetadata();
}

export default function BlogIndexPage() {
    return <BlogOverview blog={blog} />;
}

3. The /blog/[slug] post page. BlogPage loads the post from the slug, so the page stays a one-liner. With dynamicParams = false, unknown slugs 404 at the router before it renders.

// app/blog/[slug]/page.tsx
import { BlogPage } from "@daanvandenbergh/scribekit/react";
import { blog } from "../_blog";

export const dynamicParams = false; // unknown slugs 404 instead of rendering on demand

export function generateStaticParams() {
    return blog.getPostSlugs().map((slug) => ({ slug }));
}

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" };
    }
}

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    return <BlogPage blog={blog} slug={slug} />;
}

Wrap the components in your own navbar/footer; pass a heading to <BlogOverview header={...} /> if you want one above the grid.

Tip: set metadataBase (or a title) in your root layout.tsx metadata so relative URLs resolve app-wide. The helpers also set metadataBase from site.siteUrl on each page.

Writing posts

Each post is a folder in your contentDir (default: a top-level blog/) holding a body named by its locale - en.mdx (the language-neutral post.mdx also works). The slug is the folder name - blog/how-i-work/en.mdx becomes /blog/how-i-work. The front-matter contract:

---
title: "Short, specific, front-loads the primary keyword"
description: "Benefit-led summary. Used for the card, meta description, and OG/Twitter."
date: "2026-07-07"            # ISO YYYY-MM-DD - ALWAYS quote it
keywords:                     # optional
    - primary keyword
    - secondary keyword
categories:                   # optional; usually one, from a small reused taxonomy; always a list
    - Guides
author: "Jane Doe"            # optional; defaults to site.defaultAuthor, then brandName
author-image: "/assets/blog/authors/jane-doe.jpg"   # optional; avatar for the author, see below
image: "/assets/blog/how-i-work/hero.en.jpg"   # optional; see below
updated: "2026-07-08"         # optional
---

## Start the body at H2

The `title` above is your H1 - don't repeat it here.
  • Quote the date. An unquoted YAML date parses as a Date, not a string, and is dropped.
  • Categories power the overview's filter buttons - reuse a small taxonomy; the bar hides when every post shares one category. Reading time is computed from the body (no front-matter needed).
  • Hero images live in public/ and are referenced by image as a served path, one per language (hero.en.jpg, hero.fr.jpg). The scribekit-hero skill generates them and fills this field.
  • Authors: setting author shows the name in the meta row and a "Written by" bio closing the article; author-image is the avatar (a served path). Leave them unset and both spots render without a picture.

Post sidebar

BlogPage renders a right-side sidebar: a heading minimap (a TOC from the body's ##/### headings with jump links and a scroll-tracking active highlight), the estimated reading time, and similar posts. It collapses into a tap-to-expand bar on narrow viewports.

  • Similar posts are ranked by weighted-term cosine similarity over each post's keywords, title, and description; add keywords for better matches.
  • The minimap needs anchor ids on ##/### headings (injected for you). If you override h2/h3 via components, add your own ids with the exported slugify.
  • Turn it off with showSidebar={false}; customise via tocTitle, similarTitle, similarCount (default 3), and readingLabel.
<BlogPage blog={blog} slug={slug} similarCount={4} tocTitle="Contents" />

BlogSidebar is a client component (scroll-spy + mobile collapse need the browser); BlogPage stays a server component and passes it already-computed data. It's exported from /react if you want to place it yourself.

Overview: search, filters & pagination

Above the grid, BlogOverview adds:

  • Fuzzy search - filters as you type, matching (typo-tolerantly, via Fuse.js) on title, description, keywords, and categories.
  • Category filters - one button per distinct category, plus "All"; hidden when every post shares a single category.
  • Infinite scroll - reveals posts in pageSize batches (default 9) with a "Load more" fallback.

All optional - tune with props:

<BlogOverview
    blog={blog}
    linkComponent={Link}
    pageSize={12}
    searchPlaceholder="Search the blog…"
    loadMoreLabel="Show more"
    allCategoriesLabel="All posts"
/>

The interactive parts live in BlogOverviewGrid, a client component rendered by (and exported alongside) BlogOverview; the Blog instance and SEO JSON-LD stay on the server.


Docs

The Docs class turns a folder of .mdx pages into a tabbed, grouped navigation - assembled from each page's front-matter, not a separate config file. The components render the app: a top bar (DocsNavbar), a tab bar (DocsTabs), a left nav (DocsSidebar), a page with breadcrumb / meta / prev-next (DocsPage), a right-hand scroll-spy minimap (DocsToc, rendered by DocsPage), and a landing page of section cards (DocsIndex). One DocsSearchProvider owns the ⌘K palette the navbar button and keyboard shortcut share. Like the blog, DocsNavbar is optional and you own the rest of the chrome.

1. Configure one Docs instance

// app/docs/_docs.ts
import { Docs } from "@daanvandenbergh/scribekit";

export const docs = new Docs({
    contentDir: "./docs", // where your <slug>/en.mdx page folders live
    siteUrl: "https://example.com",
    brandName: "Example",
    // optional: pin the display order of the top tabs and the sidebar groups
    tabs: ["Documentation", "Guides", "API reference"],
    groups: ["Get started", "Configuration", "Reference"],
});

2. The docs shell + /docs landing page

The chrome (navbar + tab bar + left nav) is persistent, so render it in a layout around the routed page. The pieces are client components (⌘K search, sliding indicator, active-page highlight) that need the current path, so a thin "use client" wrapper reads usePathname() and lays them out inside a DocsSearchProvider. The shell classes (.scribekit-docs, .scribekit-docs-body, .scribekit-docs-main) come from the stylesheet:

// app/docs/_docs-chrome.tsx
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { DocsNavbar, DocsNavbarButton, DocsSearchProvider, DocsTabs, DocsSidebar } from "@daanvandenbergh/scribekit/react";
import type { NavTree } from "@daanvandenbergh/scribekit";

export function DocsChrome({ nav, children }: { nav: NavTree; children: ReactNode }) {
    const activePath = usePathname();
    return (
        <DocsSearchProvider nav={nav} linkComponent={Link}>
            <div className="scribekit-docs">
                <DocsNavbar
                    logo={<MyLogo />}
                    logoSize={22}
                    brandName="Example"
                    docsText="Docs"
                    linkComponent={Link}
                    // The right-hand actions - the part you'll most often edit. A list of buttons,
                    // styled to match the design (link / primary / secondary variants).
                    actions={[
                        <DocsNavbarButton key="support" href="/support" linkComponent={Link}>Support</DocsNavbarButton>,
                        <DocsNavbarButton key="dash" href="/dashboard" linkComponent={Link} variant="primary">Dashboard</DocsNavbarButton>,
                    ]}
                />
                <DocsTabs nav={nav} activePath={activePath} linkComponent={Link} />
                <div className="scribekit-docs-body">
                    <DocsSidebar nav={nav} activePath={activePath} linkComponent={Link} />
                    <main className="scribekit-docs-main">{children}</main>
                </div>
            </div>
        </DocsSearchProvider>
    );
}
// app/docs/layout.tsx
import type { ReactNode } from "react";
import { docs } from "./_docs";
import { DocsChrome } from "./_docs-chrome";

export default function DocsLayout({ children }: { children: ReactNode }) {
    return <DocsChrome nav={docs.getNavTree()}>{children}</DocsChrome>;
}
// app/docs/page.tsx
import { DocsIndex } from "@daanvandenbergh/scribekit/react";
import { docs } from "./_docs";

export function generateMetadata() {
    return docs.indexMetadata();
}

export default function DocsIndexPage() {
    return <DocsIndex docs={docs} />;
}

3. The /docs/[slug] page

// app/docs/[slug]/page.tsx
import { DocsPage } from "@daanvandenbergh/scribekit/react";
import { docs } from "../_docs";

export const dynamicParams = false; // unknown slugs 404 instead of rendering on demand

export function generateStaticParams() {
    return docs.getDocSlugs().map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    try {
        return docs.docMetadata(docs.getDoc(slug));
    } catch {
        return { title: "Page not found" };
    }
}

export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    return <DocsPage docs={docs} slug={slug} />;
}

DocsPage renders the breadcrumb, title, lead, reading time, MDX body, a "Was this page helpful?" widget, prev/next cards, and the DocsToc minimap - all from docs. Every docs component takes a lang for multi-locale docs. Docs render full-width (they're an app, not a centered article), so mount them outside any max-width container - or set --scribekit-docs-max-width to cap and centre the shell.

Writing docs pages

Each page is a folder in contentDir holding a body named by its locale (en.mdx; post.mdx also works). The slug is the folder name - docs/greeting/en.mdx becomes /docs/greeting. The front-matter contract:

---
title: "Greeting & voice"        # the H1 and default sidebar label
description: "Shape the first thing every caller hears."   # the lead + meta description
tab: "Documentation"             # optional; top-level section (the tab switcher)
group: "Configuration"           # optional; sidebar group heading
order: 2                         # optional; sort within the group (ascending)
icon: "voice"                    # optional; a glyph name from the built-in set
label: "Greeting"                # optional; sidebar label override (defaults to title)
updated: "2026-07-09"            # optional; shown in the meta row - quote it
image: "/assets/docs/greeting/hero.jpg"   # optional; OG image
keywords: ["greeting", "voice"]  # optional
hidden: false                    # optional; true = routable via direct link, but out of the nav, sitemap & noindex
---

## Start the body at H2

The `title` above is your H1 - don't repeat it here.

Navigation: tabs, groups & order

The sidebar is assembled from the pages themselves - there is no separate nav config file:

  • tab places a page under a top-level section. More than one distinct tab shows the top tab bar; a single tab hides it. Order/label with the tabs config.
  • group lists pages under an uppercase heading. Order/label with the groups config.
  • order sorts pages within their group (ascending); unordered pages sort after, then by title.
  • hidden: true keeps a page routable via direct link but out of the sidebar, prev/next, index, sitemap, and served robots: noindex (so a draft isn't crawled).

docs.getNavTree(lang) returns the resolved tree (tabs → groups → ordered pages) and drives getBreadcrumb, getAdjacent (prev/next), and DocsIndex's section cards.

Renaming a page: redirects

The slug is the folder name, so renaming the folder changes the page's public URL. List the old slug in redirects and scribekit keeps it alive as a permanent 308:

export const docs = new Docs({
    contentDir: "./docs",
    redirects: { "getting-started": "quickstart" },   // old slug -> new slug
});

Then serve it from the [slug] page. Both lines are needed: with dynamicParams = false an unrendered slug 404s at the router, so an old slug must be prerendered for the component to run at all.

import { notFound, permanentRedirect } from "next/navigation";

export function generateStaticParams() {
    return [...docs.getDocSlugs(), ...docs.getRedirectRefs().map((ref) => ref.slug)].map((slug) => ({ slug }));
}

export default async function DocPage({ params }: { params: Promise<{ slug: string }> }) {
    const { slug } = await params;
    try {
        docs.getDoc(slug);
    } catch {
        const renamedTo = docs.getRedirect(slug); // already locale-prefixed and basePath-rooted
        if (renamedTo) {
            permanentRedirect(renamedTo);
        }
        notFound();
    }
    return <DocsPage docs={docs} slug={slug} />;
}

getRedirect returns the destination URL for the requested language, so a multi-locale site needs no extra wiring - pass lang and /fr/docs/getting-started lands on /fr/docs/quickstart. A real page always wins over a redirect entry with the same slug (a stale entry is inert), chains resolve to their final destination in one hop, and a cycle 404s rather than looping.

Icons

icon names a glyph from the built-in set (book, rocket, workflow, gear, globe, clock, phone, voice, calendar, mail, link, grid, list, code, shield, document, …). Unknown names fall back to a neutral document glyph. Pass your own set with the renderIcon prop on DocsSidebar / DocsIndex: renderIcon={(name) => <MyIcon name={name} />}.

Command palette, minimap & prev/next

  • ⌘K / Ctrl-K (or the navbar's search button) opens the DocsSearchProvider command palette - a fuzzy search (Fuse.js) over every page's title, label, group, and tab, with ↑/↓ + Enter. It's a proper modal (traps Tab, returns focus on close).
  • DocsPage injects anchor ids into ##/### headings (via slugify) so the DocsToc minimap and its scroll-spy resolve. If you override h2/h3 via components, add your own ids.
  • Prev/next cards come from the sidebar reading order (getAdjacent); the "Was this page helpful?" widget is self-contained (pass onFeedback to DocsPage to record the vote).

Deploy to GitHub Pages

A scribekit docs site is already static-export-ready - the Docs class reads the filesystem at build time, pages render as server components, search is client-side, and every route ships generateStaticParams - so next build with output: 'export' prerenders the whole site to static HTML with no library code changes.

The easiest path is the scribekit-docs-github-pages skill: run it in Claude Code and it sets everything up for you - merges output: 'export' (and images: { unoptimized: true }) into your next.config, adds public/.nojekyll, writes .github/workflows/deploy.yml, points siteUrl at your Pages origin, and handles the static-export blockers below. If your docs/ is just a folder of MDX with no app rendering it yet, the skill also scaffolds the host app (the flat single-locale layout below) from the corpus first. Then just push - the workflow's configure-pages step sets enablement: true, so it turns Pages on itself on the first run (if an org policy blocks that, enable it once by hand: repo Settings -> Pages -> Source: "GitHub Actions").

Not using Claude Code? The workflow is shipped ready to copy at node_modules/@daanvandenbergh/scribekit/skills/scribekit-docs-github-pages/assets/deploy.yml - drop it in .github/workflows/, add the next.config lines, set siteUrl, push. It enables Pages (enablement: true) and derives your Pages base path from actions/configure-pages@v6, passing it to the build as NEXT_PUBLIC_BASE_PATH so next.config and the hero <img> resolve the correct /<repo> prefix automatically.

Static export changes two routing behaviours (both are conveniences, not the docs themselves):

  • Middleware clean-URLs break. The multi-locale trick that serves the default language at a bare /docs/... (a proxy.ts/middleware.ts rewrite) can't run on a static host. Set prefixDefaultLocale: true so every locale is a real static path (/en/docs/..., /fr/docs/...) and drop the middleware. A single-locale site has no middleware and exports as-is.
  • Renamed-slug redirects degrade. The build still succeeds and renamed slugs still redirect - Next turns the request-time 308 into a client-side redirect (the old URL is a near-empty 200 whose JS navigates on). The only loss is SEO / no-JS: to a crawler the old URL is a blank 200 with no true 308 or canonical. Leave it if those URLs aren't SEO-critical, or have the skill emit a static <meta refresh> + canonical stub instead.

Hosting: a project site works, a root site is that bit cleaner. On a project site (<owner>.github.io/<repo>/) three raw paths a corpus carries would otherwise 404 under the /<repo> subpath - in-body prose links, hero images, and absolute SEO URLs. The skill's scaffold handles the first two automatically: it routes the MDX a through next/link and renders the hero through a base-path-aware <img>, and the workflow feeds the base path in from configure-pages. So heroes, links, and assets all resolve on a plain project site - no custom domain required. The one residue is scribekit's absolute SEO URLs (canonical/sitemap/OG omit /<repo>); if that matters, a custom domain (add a public/CNAME) or a user/org <owner>.github.io site serves at the root with an empty base path, where even those are correct.


Multiple locales

Applies to both blog and docs (the examples below use /blog; docs is identical with /docs). Optionally publish the same content in several languages, each at its own SEO-friendly URL with correct hreflang / og:locale / inLanguage. Fully opt-in: with no locales configured, everything here is inert and behaves as a single-language site.

File layout - one file per language in the folder. Each language is a <lang>.<ext> file in the same <slug>/ folder, sharing the base slug - the default included, named by its own code (en.mdx; post.<ext> also resolves as a fallback):

blog/getting-started/en.mdx     ->  /blog/getting-started        (default, e.g. en)
blog/getting-started/fr.mdx     ->  /fr/blog/getting-started     (fr translation, same slug)

A file's language comes from its name (never front-matter, so it can't drift). The default is served at the clean, unprefixed /blog/<slug>; every other language is prefixed (/<lang>/blog/<slug>). There is no silent fallback - only files that exist get a route and a hreflang entry.

Configure the locales on the one Blog (or Docs) instance:

// app/[lang]/blog/_blog.ts  (colocated with the routes; the leading _ keeps Next from routing it)
export const blog = new Blog({
    contentDir: "./blog",
    siteUrl: "https://example.com",
    brandName: "Example",
    locales: [
        { code: "en", label: "English" },
        { code: "fr", label: "Français", dateLocale: "fr-FR" }, // label + dateLocale are optional
    ],
    defaultLocale: "en",
});

Route it as one [lang]/blog subtree - two files, overview + posts. generateStaticParams emits every locale (including the default), so /en/blog/... is prerendered as the render target for the unprefixed /blog/...:

// app/[lang]/blog/page.tsx  - overview at /<lang>/blog
import { BlogOverview } from "@daanvandenbergh/scribekit/react";
import { blog } from "./_blog";

export const dynamicParams = false;
export function generateStaticParams() {
    return blog.locales.map((l) => ({ lang: l.code }));
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
    return blog.overviewMetadata((await params).lang);
}
export default async function BlogIndex({ params }: { params: Promise<{ lang: string }> }) {
    return <BlogOverview blog={blog} lang={(await params).lang} />;
}
// app/[lang]/blog/[slug]/page.tsx  - post at /<lang>/blog/<slug>
import { BlogPage } from "@daanvandenbergh/scribekit/react";
import { PostNotFoundError } from "@daanvandenbergh/scribekit";
import { notFound } from "next/navigation";
import { blog } from "../_blog";

export const dynamicParams = false;
export function generateStaticParams() {
    return blog.getPostRefs().map((ref) => ({ lang: ref.lang, slug: ref.slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ lang: string; slug: string }> }) {
    const { lang, slug } = await params;
    try { return blog.postMetadata(blog.getPost(slug, lang)); } catch { return { title: "Post not found" }; }
}
export default async function BlogPostPage({ params }: { params: Promise<{ lang: string; slug: string }> }) {
    const { lang, slug } = await params;
    try { blog.getPost(slug, lang); } catch (err) { if (err instanceof PostNotFoundError) notFound(); throw err; }
    return <BlogPage blog={blog} slug={slug} lang={lang} />;
}

Serve the default language at the clean /blog with a proxy.ts (project root; on Next <= 15 name it middleware.ts and export middleware). It rewrites /blog/... onto /en/blog/... (URL bar stays /blog/...) and redirects /en/blog/... back to /blog/..., so the default locale has one canonical, unprefixed URL:

// proxy.ts
import { NextResponse, type NextRequest } from "next/server";

const DEFAULT_LOCALE = "en"; // hard-coded: the proxy runs on the edge; the Blog class is server-only

export function proxy(request: NextRequest) {
    const { pathname } = request.nextUrl;
    const prefix = `/${DEFAULT_LOCALE}`;
    if (pathname === `${prefix}/blog` || pathname.startsWith(`${prefix}/blog/`)) {
        const url = request.nextUrl.clone();
        url.pathname = pathname.slice(prefix.length);      // /en/blog/x -> /blog/x  (redirect)
        return NextResponse.redirect(url, 308);
    }
    if (pathname === "/blog" || pathname.startsWith("/blog/")) {
        const url = request.nextUrl.clone();
        url.pathname = `${prefix}${pathname}`;             // /blog/x -> /en/blog/x  (rewrite)
        return NextResponse.rewrite(url);
    }
}

export const config = { matcher: ["/blog", "/blog/:path*", "/en/blog", "/en/blog/:path*"] };

Because the default is reached via a rewrite (rendered on demand), guard the post page's getPost with notFound() as shown above rather than relying only on dynamicParams = false.

Prefer no proxy? Set prefixDefaultLocale: true. Every locale is then prefixed, including the default (/en/blog/...), so the same [lang]/blog tree serves everything with no rewrite - at the cost of /en/ in the default language's URLs. Add a next.config.mjs redirect from bare /blog to /en/blog if you like.

BlogOverview, BlogPage, and the docs components take a lang prop, build links via the shared localePath (so they can't drift from the canonical/hreflang metadata), and format dates in each language's dateLocale.

Built-in UI copy (search placeholder, "load more", "min read", "on this page", …) auto-translates to the lang, covering the EU's 24 official languages and falling back to English - so a /fr/blog page reads in French with no extra wiring. Every label prop still overrides its translation. The copy is powered by @daanvandenbergh/i18nkit, whose type-safe catalog guarantees every string is translated at compile time. The resolvers and catalog are exported from /react (blogLabels, docsLabels, CATALOG, and the ui i18nkit instance) if you want to read a label or add a language.

The components do not render a language picker (blog) - placement and styling vary too much to bake in. It's a few lines with blog.getTranslations(slug) and the exported localePath (docs get DocsLanguagePicker in the navbar for free):

import { localePath } from "@daanvandenbergh/scribekit";

function LanguageSwitcher({ slug, current }: { slug: string; current: string }) {
    return (
        <nav aria-label="Language">
            {blog.getTranslations(slug).map((code) => (
                <a key={code} hrefLang={code}
                   href={localePath({ basePath: blog.site?.basePath, defaultLocale: blog.defaultLocale, prefixDefaultLocale: blog.prefixDefaultLocale, lang: code, slug })}
                   aria-current={code === current ? "true" : undefined}>
                    {blog.locales.find((l) => l.code === code)?.label ?? code}
                </a>
            ))}
        </nav>
    );
}

Set the page's <html lang> per request from your own layout - that's your app chrome, not the package's job.

⚠️ linkComponent must render href as given

Never pass a locale-rewriting <Link> wrapper as linkComponent. Every href scribekit hands a link component is already final: it is built by localePath, so the locale is resolved (/docs/<slug> on the default language, /nl/docs/<slug> on Dutch). The contract is render this href verbatim - next/link and a bare <a> honour it.

Most i18n apps have a <LocaleLink> that prefixes the active locale onto an href, and passing it here is the natural instinct. It is a bug, and a nasty one, because it looks like it works:

// ❌ Wrong - a link wrapper that rewrites hrefs
<DocsSidebar nav={nav} linkComponent={LocaleLink} />

// ✅ Right - render the href exactly as scribekit built it
<DocsSidebar nav={nav} linkComponent={Link} />

Re-prefixing an already-prefixed href is usually a no-op, so every sidebar, tab, card, and search hit navigates fine. The one thing it breaks is the language picker - the only component that emits an href for a locale other than the active one. On a Dutch page, DocsLanguagePicker's English entry is the bare /docs/<slug>; a rewriting wrapper turns it back into /nl/docs/<slug>, so the reader clicks "English", stays in Dutch, and can never leave. Silent, and it disables precisely the control meant to fix it.

The rule: an href you author bare (/pricing, homeHref) gets your locale-aware link; an href scribekit hands you gets a plain one.

Cookie-based locale preference? Pass onSelect. If your middleware resolves a bare URL's language from a stored preference (a cookie or Accept-Language), a plain link cannot switch to the default locale: navigating to the unprefixed /docs/<slug> gets 307'd straight back to the old stored language, so the reader clicks "English", stays in Dutch, and can never leave. Give DocsLanguagePicker an onSelect and it hands you the chosen code instead of navigating (the click is preventDefaulted), so you can write the preference first and then route. Wire it to your locale setter - with i18nkit that is one line, since useSetLocale() writes the cookie synchronously and its provider does the navigation:

const setLocale = useSetLocale();
<DocsLanguagePicker locales={docs.locales} currentLang={lang} onSelect={setLocale} /* … */ />

The href/hrefLang stay on each anchor either way, so the links remain crawlable. Leave onSelect unset on a purely URL-routed site and the links navigate on their own, as before.

i18n sitemap. blog.sitemapEntries() (and docs.sitemapEntries()) returns one entry per (slug, lang) with its absolute URL and hreflang alternates.languages (every translation plus x-default) - assignable straight to a Next.js sitemap.ts (needs siteUrl/brandName):

// app/sitemap.ts
import type { MetadataRoute } from "next";
import { blog } from "./[lang]/blog/_blog";

export default function sitemap(): MetadataRoute.Sitemap {
    return blog.sitemapEntries();
}

See demo/ for a complete example: every post has a fr.mdx beside its en.mdx, the single [lang]/blog tree lives under demo/app/, and demo/proxy.ts serves the default language at the clean /blog.


Theming

Every colour is a CSS custom property with a sensible light-theme default. Override any from your own CSS - no config, no build step:

:root {
    --scribekit-primary: #6d5df6;      /* links, hover accents, "read more" */
    --scribekit-ink: #0b1b36;          /* headings and body text */
    --scribekit-ink-muted: #4a5568;    /* dates, descriptions */
    --scribekit-surface: #ffffff;      /* card background */
    --scribekit-border: #e5eaf0;       /* card/hr/table borders */
    --scribekit-bg-subtle: #f7f9fc;    /* code blocks, table headers */
    /* --scribekit-overview-width, --scribekit-post-width, --scribekit-card-shadow, --scribekit-font-mono ... */
}

Class names are namespaced .scribekit-* and MDX body tags are scoped under .scribekit-prose, so nothing leaks into the rest of your site. Fonts are inherited from your app.

Docs reuse these tokens plus a few of their own (all with defaults): --scribekit-docs-nav-width, --scribekit-doc-width, --scribekit-docs-toc-width (the three column widths), and --scribekit-code-bg / --scribekit-code-ink (the dark code slab). Docs classes are namespaced .scribekit-docs-* / .scribekit-doc*.


MDX plugins

GFM is on by default. BlogPage and DocsPage render MDX with remark-gfm already applied, so tables, strikethrough, task lists, and autolinks just work - along with headings, lists, links, blockquotes, inline code, and fenced code blocks, all styled out of the box. (Pipe tables are a GFM extension, not core Markdown: without the plugin a | a | b | table does not error, it silently renders as a paragraph of literal | characters. That is a content bug you only see on the published page, so it is not left to the consumer to remember.)

To add anything extra - syntax highlighting, autolinked headings - pass options through to next-mdx-remote (works the same on BlogPage and DocsPage). Your plugins are merged with GFM, never replace it:

import rehypePrettyCode from "rehype-pretty-code";

<BlogPage
    blog={blog}
    slug={slug}
    mdxOptions={{ mdxOptions: { rehypePlugins: [rehypePrettyCode] } }}   // gfm stays enabled
    components={{ pre: MyCodeBlock }}
/>;

API reference

new Blog(config)

The site attributes are passed directly (flattened) into the config:

| Config | Default | Description | | --- | --- | --- | | contentDir | (required) | Folder of <slug>/<lang>.mdx posts (one folder per post; default en.mdx). Relative to process.cwd(), or absolute. | | siteUrl / brandName | - | Site origin + brand name. Provide both to enable the SEO methods and JSON-LD. | | defaultAuthor | brandName | Author used when a post omits its own. | | basePath | /blog | Route the blog is mounted at (used to build URLs). | | description | - | Blog index meta description. | | extension | .mdx | Post file extension (each file is <slug>/<lang><ext>, e.g. en.mdx / fr.mdx). | | locale | en-GB | Locale for formatDate (default language). | | locales | - | Languages this blog is published in; see Multiple locales. Leave unset for a single-language blog. | | defaultLocale | first locales code, else base subtag of locale | The default language (the x-default hreflang target), served unprefixed unless prefixDefaultLocale. | | prefixDefaultLocale | false | When true, the default locale is URL-prefixed too (/en/blog/<slug>), so every locale routes through one [lang] segment. | | organizationId | - | @id of an Organization in your site-wide JSON-LD. When set, BlogPosting.publisher and the overview CollectionPage.publisher reference it by @id instead of inlining a duplicate. | | authorId | - | @id of a Person/Organization your site defines as the blog author. When set, BlogPosting.author references it by @id instead of inlining a name-only Organization. | | websiteId | - | @id of a WebSite your site defines. When set, BlogPosting and the overview CollectionPage gain an isPartOf reference to it. |

Stitch into an existing site graph (better SEO). If your app already emits site-wide schema.org JSON-LD with stable @ids, pass organizationId / authorId / websiteId so the blog references those entities by @id instead of inlining its own copies - search engines then merge the blog into your single knowledge-graph entity. Leave them unset to keep the self-contained default output.

Methods: getPostSlugs(), getPost(slug, lang?) (throws PostNotFoundError), getAllPosts(lang?) (newest first), getAllCategories(lang?), formatDate(iso, lang?), readingMinutes(post), tableOfContents(post), similarPosts(post, limit?), and - when siteUrl/brandName are set - overviewMetadata(lang?), postMetadata(post), overviewJsonLd(posts, lang?), postJsonLd(post), sitemapEntries(). Multi-language: getPostRefs() (every (slug, lang) pair, for generateStaticParams), getTranslations(slug), dateLocale(lang?). The assembled config is exposed as blog.site / blog.locale / blog.locales / blog.defaultLocale / blog.prefixDefaultLocale. The pure helpers behind the components (readingMinutes, tableOfContents, similarPosts, slugify, collectCategories, localePath) are also exported from the package root.

<BlogOverview> / <BlogPage>

Both take the blog instance and derive their data, date locale, and JSON-LD from it. BlogOverview also accepts posts to override the auto-derived list; BlogPage takes the slug (loading the post and 404-ing on an unknown one). Both accept lang for multi-locale blogs (defaults to blog.defaultLocale).

Shared optional props: basePath (defaults to blog.site.basePath), imgComponent (default "img" - pass next/image), linkComponent (default "a" - pass next/link; it must render href as given - never a locale-rewriting wrapper, see the contract). BlogOverview also takes header, emptyLabel, readMoreLabel, plus the search, filter & pagination props. BlogPage also takes components, mdxOptions, showBackLink, backLabel, plus the sidebar props.

Next 16 (Turbopack): next/link / next/image imported in a server component are no longer serializable client references, so passing them to BlogOverview (whose grid is a client component) fails the build with "Functions cannot be passed directly to Client Components". Re-export them from a "use client" file and pass that instead:

// app/blog/_link.tsx
"use client";
export { default as Link } from "next/link";

new Docs(config)

Same flattened site attributes as new Blog(config) (siteUrl / brandName / defaultAuthor / description / organizationId / authorId / websiteId / locale / locales / defaultLocale / prefixDefaultLocale), plus:

| Config | Default | Description | | --- | --- | --- | | contentDir | (required) | Folder of <slug>/<lang>.mdx docs (one folder per page; default en.mdx). Relative to process.cwd(), or absolute. | | basePath | /docs | Route the docs are mounted at (used to build URLs). | | tabs | - | Display order + labels for the top-level tabs. Each entry is a tab id (matching a page's tab) or { id, label }. Purely presentational. | | groups | - | Display order + labels for the sidebar group headings, same semantics as tabs. | | redirects | - | Old slug → new slug for renamed pages, e.g. { "getting-started": "quickstart" }. A real page always wins over an entry with the same slug; chains resolve in one hop; a cycle 404s instead of looping. |

Methods: getDocSlugs(), getDocRefs() (every (slug, lang) pair), getRedirect(slug, lang?) (the 308 destination URL for a renamed slug, else undefined), getRedirectRefs() (every redirected (slug, lang) pair - spread it into generateStaticParams), getDoc(slug, lang?) (throws DocNotFoundError), getAllDocs(lang?), getTranslations(slug), getNavTree(lang?), getBreadcrumb(slug, lang?), getAdjacent(slug, lang?) (prev/next), tableOfContents(doc), readingMinutes(doc), formatDate(iso, lang?), dateLocale(lang?), and - when siteUrl/brandName are set - docMetadata(doc), indexMetadata(lang?), docJsonLd(doc), indexJsonLd(lang?), sitemapEntries(). The config is exposed as docs.site / docs.locale / docs.locales / docs.defaultLocale / docs.prefixDefaultLocale.

<DocsNavbar> / <DocsTabs> / <DocsSidebar> / <DocsToc> / <DocsPage> / <DocsIndex>

Every component below takes linkComponent (default "a" - pass next/link). It must render href as given: scribekit has already resolved the locale, so a locale-rewriting <Link> wrapper silently breaks the language picker - see the contract.

  • DocsSearchProvider (client) - owns the shared ⌘K command palette; wrap your docs shell in it. Props: nav, lang, linkComponent, searchPlaceholder, searchEmptyLabel, renderIcon. Any descendant opens it via useDocsSearch().open(), or drop a DocsSearchButton (placeholder, lang, className).
  • DocsNavbar (client, optional) - the top bar. Props: logo, logoSize (default 22), brandName, docsText (the pill, default "Docs"; ""/null hides it), homeHref, linkComponent, lang, showSearch (default true), searchPlaceholder, actions (the right-hand section - the part most sites edit), and languagePicker (a slot before the actions). actions takes arbitrary nodes: drop in DocsNavbarButtons, or your own site's CTA component. A link that carries a class of its own keeps its own styling; only a genuinely raw, unstyled <a> picks up the navbar's muted fallback colour.
  • DocsNavbarButton (optional) - a navbar action: variant "link" / "primary" / "secondary". Props: href (renders a link, else a <button>) / onClick, variant, linkComponent, icon, target, rel, ariaLabel. Pass a list to DocsNavbar's actions.
  • DocsLanguagePicker (client, optional) - the navbar language switcher: a flag-and-caret trigger opening a menu of the docs' languages, each a hreflang link. Auto-hides for single-locale docs, so you can always render it. Flags come from i18nkit's localeFlag (override with renderFlag). Props: locales, currentLang, activePath, basePath, defaultLocale, prefixDefaultLocale, linkComponent, onSelect ((code) => void - takes over the switch instead of navigating; required if your middleware resolves a bare URL's language from a cookie, see above), lang, renderFlag, changeLanguageLabel, headingLabel. Pass it to DocsNavbar's languagePicker.
  • DocsTabs (client) - the top tab bar (underline tabs + sliding indicator); each tab links to its section's first page. Props: nav, activePath, linkComponent, lang, label. Renders nothing for a single-tab tree.
  • DocsSidebar (client) - the left nav (active tab's groups) + ⌘K palette. Props: nav, activePath, linkComponent, lang, renderIcon, and label overrides (searchPlaceholder, searchEmptyLabel, label).
  • DocsToc (client) - the right "on this page" minimap with scroll-spy. Rendered by DocsPage. Props: toc, title.
  • DocsPage (server) - one page (article + DocsToc). Props: docs, slug, lang, linkComponent, components / mdxOptions, showToc / showBreadcrumb / showFeedback, onFeedback, and label overrides (tocTitle, previousLabel, nextLabel, feedbackQuestion, readingLabel, …).
  • DocsIndex (server) - the landing page. Props: docs, lang, linkComponent, title, description, header (replace the hero), renderIcon.

The built-in UI copy (search, prev/next, "was this helpful?", "on this page", "updated …") is translated into 24 EU languages and picked by lang; every string is overridable via the props above.


Notes

  • Static by default. With generateStaticParams + dynamicParams = false, posts and docs are prerendered at build time (SSG). If you instead read at request time with output: "standalone", pass an absolute contentDir and add the content dir to outputFileTracingIncludes so the files ship with your server bundle.
  • Reads are cached, and the cache checks the file. Each content file is read and normalised once per Blog/Docs instance, then re-used until its modification time or size changes - so a build does not re-parse the whole corpus for every page. The check is a stat per read, which costs a fraction of the work it skips, and it means there is nothing to configure or clear: next dev picks up an edit immediately, and a long-lived output: "standalone" server picks up content that changes underneath it.
  • Server-only classes. Blog and Docs read the filesystem - keep them in server components / route files and pass the resulting data to the components as props (exactly what the snippets above do). DocsTabs / DocsSidebar / DocsToc are client components, so they take the already-computed nav tree / toc as props.