@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

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
Blogclass reads.mdxposts from a folder and builds their SEO metadata + JSON-LD.<BlogOverview>renders the/bloggrid (fuzzy search, category filters, infinite scroll);<BlogPage>renders a post with a heading minimap, reading time, and similar posts. - Docs - the
Docsclass turns a folder of.mdxpages 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/scribekitPeer 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-remoteImport 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-pagesscribekit-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 --scanreports only). Each post's hero is generated byscribekit-hero, which thewriteflow 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, orreorganizethe 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).--scanreports only. It reusesscribekit-blog's house style and research protocol, so installscribekit-blogalongside 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, addsoutput: 'export'to your Next config, writes the deploy workflow, pointssiteUrlat 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 atitle) in your rootlayout.tsxmetadata so relative URLs resolve app-wide. The helpers also setmetadataBasefromsite.siteUrlon 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 byimageas a served path, one per language (hero.en.jpg,hero.fr.jpg). Thescribekit-heroskill generates them and fills this field. - Authors: setting
authorshows the name in the meta row and a "Written by" bio closing the article;author-imageis 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, anddescription; addkeywordsfor better matches. - The minimap needs anchor
ids on##/###headings (injected for you). If you overrideh2/h3viacomponents, add your ownids with the exportedslugify. - Turn it off with
showSidebar={false}; customise viatocTitle,similarTitle,similarCount(default 3), andreadingLabel.
<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, andcategories. - Category filters - one button per distinct category, plus "All"; hidden when every post shares a single category.
- Infinite scroll - reveals posts in
pageSizebatches (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:
tabplaces 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 thetabsconfig.grouplists pages under an uppercase heading. Order/label with thegroupsconfig.ordersorts pages within their group (ascending); unordered pages sort after, then by title.hidden: truekeeps a page routable via direct link but out of the sidebar, prev/next, index, sitemap, and servedrobots: 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
DocsSearchProvidercommand 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). DocsPageinjects anchorids into##/###headings (viaslugify) so theDocsTocminimap and its scroll-spy resolve. If you overrideh2/h3viacomponents, add your ownids.- Prev/next cards come from the sidebar reading order (
getAdjacent); the "Was this page helpful?" widget is self-contained (passonFeedbacktoDocsPageto 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/...(aproxy.ts/middleware.tsrewrite) can't run on a static host. SetprefixDefaultLocale: trueso 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
redirectsdegrade. 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-empty200whose JS navigates on). The only loss is SEO / no-JS: to a crawler the old URL is a blank200with 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]/blogtree serves everything with no rewrite - at the cost of/en/in the default language's URLs. Add anext.config.mjsredirect from bare/blogto/en/blogif 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 orAccept-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. GiveDocsLanguagePickeranonSelectand it hands you the chosen code instead of navigating (the click ispreventDefaulted), so you can write the preference first and then route. Wire it to your locale setter - with i18nkit that is one line, sinceuseSetLocale()writes the cookie synchronously and its provider does the navigation:const setLocale = useSetLocale(); <DocsLanguagePicker locales={docs.locales} currentLang={lang} onSelect={setLocale} /* … */ />The
href/hrefLangstay on each anchor either way, so the links remain crawlable. LeaveonSelectunset 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/imageimported in a server component are no longer serializable client references, so passing them toBlogOverview(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 viauseDocsSearch().open(), or drop aDocsSearchButton(placeholder,lang,className).DocsNavbar(client, optional) - the top bar. Props:logo,logoSize(default 22),brandName,docsText(the pill, default"Docs";""/nullhides it),homeHref,linkComponent,lang,showSearch(defaulttrue),searchPlaceholder,actions(the right-hand section - the part most sites edit), andlanguagePicker(a slot before the actions).actionstakes arbitrary nodes: drop inDocsNavbarButtons, 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 toDocsNavbar'sactions.DocsLanguagePicker(client, optional) - the navbar language switcher: a flag-and-caret trigger opening a menu of the docs' languages, each ahreflanglink. Auto-hides for single-locale docs, so you can always render it. Flags come from i18nkit'slocaleFlag(override withrenderFlag). 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 toDocsNavbar'slanguagePicker.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 byDocsPage. 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 withoutput: "standalone", pass an absolutecontentDirand add the content dir tooutputFileTracingIncludesso 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/Docsinstance, 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 astatper read, which costs a fraction of the work it skips, and it means there is nothing to configure or clear:next devpicks up an edit immediately, and a long-livedoutput: "standalone"server picks up content that changes underneath it. - Server-only classes.
BlogandDocsread 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/DocsTocare client components, so they take the already-computednavtree /tocas props.
