ScribekitDocs

API reference

Every export, option, method, default, type, prop, icon name, and CSS token in @daanvandenbergh/scribekit.

47 min readUpdated 11 August 2026
API reference

@daanvandenbergh/scribekit publishes three entry points: the root (the Blog and Docs classes plus the framework-free helpers), /react (the components), and /styles.css (the stylesheet). This page lists every public export, its signature, and its default. To learn the wiring, read Getting started; for task steps, read the blog guide or the docs guide.

The entry points

SubpathResolves toExports
@daanvandenbergh/scribekitdist/index.jsBlog, Docs, their errors, SEO builders, nav builders, shared helpers, shared types
@daanvandenbergh/scribekit/reactdist/react/index.jsBlog components, docs components, the i18n core
@daanvandenbergh/scribekit/styles.cssdist/react/styles.cssThe stylesheet. Import once in your app

The root entry re-exports ./shared, ./blog, and ./docs in that order. Both the Blog and Docs class modules carry import "server-only" and read the filesystem, so they resolve only in a server component, a route handler, or a build script. Importing either from a client component is a build error.

Both read it through one shared, package-internal module that owns every node:fs call, the path-traversal guard, and the read cache. It is not exported and you never construct it, but it is why a page renders the same content whether it is the first read or the fiftieth: each file is read and normalised once, then re-used until its mtime or size changes on disk. Editing a post in next dev shows up immediately, and there is no cache to warm, clear, or configure.

import { Blog, Docs, slugify, formatDate, localePath } from "@daanvandenbergh/scribekit";
import { BlogPage, DocsPage, DocsSidebar } from "@daanvandenbergh/scribekit/react";
import "@daanvandenbergh/scribekit/styles.css";

Peer dependencies: react (>=18) is the only hard one. next (>=14), react-dom (>=18), and next-mdx-remote (^5 || ^6) are all marked optional in peerDependenciesMeta, so npm stays quiet for a consumer who only imports the Blog or Docs class - though BlogPage and DocsPage need next, react-dom, and next-mdx-remote at render time.

Real dependencies, installed for you: @daanvandenbergh/i18nkit, fuse.js, gray-matter, remark-gfm, and server-only.

Docs

new Docs(config: DocsConfig)

DocsConfig extends Partial<SiteConfig>, so the site attributes are flat on the config object. The constructor does not throw.

DocsConfig options

OptionTypeDefaultPurpose
contentDirstringrequiredDirectory holding one <slug>/ folder per page. A relative path resolves against process.cwd()
extensionstring".mdx"Page file extension, leading dot included
basePathstring"/docs"Route the docs are mounted at. "" mounts at the site root (/<slug>)
localestring"en-GB"BCP 47 locale used by formatDate
localesLocaleConfig[][]Languages published. Each label defaults to its code. Unset means single-language
defaultLocalestringconfig.defaultLocale, else locales[0].code, else locale.split("-")[0], else "en"Locale served without a URL prefix, and the x-default target
prefixDefaultLocalebooleanfalseWhen true, the default locale is URL-prefixed too
trailingSlashbooleantrueWhen true, every built URL ends in a slash (/docs/quickstart/). Must match the host app's next.config trailingSlash
tabsNavConfigEntry[][]Display order and labels for top-level tabs. Purely presentational
groupsNavConfigEntry[][]Display order and labels for sidebar groups
redirectsRecord<string, string>{}Old slug to new slug, for renamed pages
siteUrlstringundefinedAbsolute origin. Sets metadataBase and absolute JSON-LD URLs
brandNamestringundefinedTitle suffix, og:siteName, JSON-LD publisher
defaultAuthorstringundefined, falls back to brandNameAuthor used when a page omits one
descriptionstringundefined, falls back to `The ${brandName} documentation.`Index page description
organizationIdstringundefined, which inlines a standalone Organization publisher@id of an existing Organization
authorIdstringundefined, which inlines a name-only author@id of a Person or Organization author
websiteIdstringundefined, which omits isPartOf@id of the host WebSite entity

this.site is assembled only when both siteUrl and brandName are defined; otherwise it is undefined and every SEO method throws. site.basePath is always the resolved basePath.

Public fields

FieldType
readonly localestring
readonly localesLocaleConfig[]
readonly defaultLocalestring
readonly prefixDefaultLocaleboolean
readonly trailingSlashboolean
readonly tabsNavConfigEntry[]
readonly groupsNavConfigEntry[]
readonly siteSiteConfig | undefined

Nothing else is exposed. basePath and redirects are private fields; contentDir and extension are not fields at all - they are passed straight into the internal content store at construction time and never held on the instance.

Methods

Every lang? parameter defaults to this.defaultLocale.

SignatureReturnsThrows
getDocSlugs()string[] of distinct slugs across all languagesDuplicateDocError
getDocRefs(){ slug: string; lang: string }[], every pair, including hidden pagesDuplicateDocError
getRedirect(slug, lang?)string | undefined. A root-relative, locale-prefixed destination; undefined means 404DuplicateDocError
getRedirectRefs(){ slug: string; lang: string }[], one per live redirect per language; inert entries filteredDuplicateDocError
getDoc(slug, lang?)DocDocNotFoundError
getAllDocs(lang?)DocMeta[] for one language in on-disk order, including hiddenDuplicateDocError
getTranslations(slug)string[] of language codes, default first. Does not filter hiddenDuplicateDocError
getNavTree(lang?)NavTree. hidden pages excludedDuplicateDocError
getBreadcrumb(slug, lang?)Breadcrumb | undefined; undefined for hidden or unknownDuplicateDocError
getAdjacent(slug, lang?)Adjacent; {} for hidden or unknownDuplicateDocError
dateLocale(lang?)string, the resolved BCP 47 date localeno
formatDate(iso, lang?)string. An unparseable value is returned as-isno
readingMinutes(doc)number, whole minutes, floored at 1no
tableOfContents(doc)TocEntry[] from ## and ### headingsno
docMetadata(doc)PageMetadataError when site is unset
indexMetadata(lang?)PageMetadataError when site is unset
docJsonLd(doc)JsonLd: TechArticle + BreadcrumbListError when site is unset
indexJsonLd(lang?)JsonLd: CollectionPage + BreadcrumbList + ItemListError when site is unset
sitemapEntries()SitemapEntry[], one per non-hidden (slug, lang), with hreflang alternatesError when site is unset

The five SEO methods route through a private requireSite(), which throws this message verbatim:

This Docs was created without a `site` config; pass `siteUrl` and `brandName` to use the SEO helpers.

dateLocale(lang?) resolves in this order: the matched locale's own dateLocale; then, if the code equals defaultLocale, the instance locale; then the matched code; then the instance locale. An unconfigured language returns the instance locale, not the code.

Page file resolution

entries() walks the immediate subdirectories of contentDir only. There is no recursion and no nested slugs, and a missing contentDir yields []. Per <slug>/ folder it checks post<ext> first, then <defaultLocale><ext>; both present is a DuplicateDocError. It then checks each configured non-default locale as <code><ext>. A <slug>/fr.mdx whose fr is not in config.locales is never discovered.

Reading one page uses the opposite priority. getDoc() delegates to the internal content store, whose candidate list is:

const candidates =
    lang === this.defaultLocale
        ? [`${slug}/${lang}${this.extension}`, `${slug}/post${this.extension}`]
        : [`${slug}/${lang}${this.extension}`];

For the default locale, <defaultLocale><ext> wins and post<ext> is the fallback. A non-default locale has no post fallback. A resolved path that escapes contentDir throws DocNotFoundError.

hidden

A hidden: true page stays in entries(), getDocRefs(), and getAllDocs(), so generateStaticParams still renders it and a direct link works. It is excluded from the nav tree, and therefore from getAdjacent (returns {}), getBreadcrumb (returns undefined), and indexJsonLd's ItemList. It receives robots: { index: false }, and is dropped from the sitemap, from siblings' hreflang, and from JSON-LD translation cross-links.

redirects

getRedirect(slug, lang?) resolves against three rules, in order:

  1. A real page always wins. An entry whose source slug still exists on disk is inert.
  2. Chains resolve in one hop. { a: "b", b: "c" } sends a straight to c.
  3. Cycles yield undefined, so the URL 404s rather than looping.

The returned string is already locale-prefixed and base-rooted, ready for Next's permanentRedirect(). getRedirectRefs() exists because a route with dynamicParams = false 404s an unrendered slug at the router, before the page component can redirect.

export function generateStaticParams() {
    return [...docs.getDocRefs(), ...docs.getRedirectRefs()];
}

DocMeta

The normalized front matter of one docs page. slug and lang are injected by the reader and are never read from YAML.

FieldTypeRequiredNormalization
slugstringyesThe directory name
langstringyesFrom filename resolution
titlestringyesText coercion, else the slug
descriptionstringyesText coercion, else ""
tabstring?noText coercion
groupstring?noText coercion
ordernumber?noKept only if typeof === "number" and Number.isFinite
iconstring?notypeof data.icon === "string", strict
labelstring?noText coercion
keywordsstring[]?noArray.isArray then map/filter; a non-array becomes undefined
imagestring?notypeof data.image === "string", strict
datestring?noisoDateString(data.date)
updatedstring?noisoDateString(data.updated)
readingTimenumber?set in practicereadingMinutes(content), computed from the body. A front-matter value is ignored
hiddenboolean?nodata.hidden === true ? true : undefined, strict identity

Which YAML types are silently dropped

Text coercion is the rule applied to title, description, tab, group, label, and each keywords element. It accepts exactly three shapes: a string as-is; a finite number, stringified; and a Date, converted to YYYY-MM-DD. Everything else becomes undefined. The coercion step itself is internal and not exported; only its effect is observable. Nothing errors, so a mistyped field is invisible until the page misbehaves.

YAMLResult
order: "3"Dropped. The page sorts into the unordered bucket, after every ordered sibling
hidden: "true" or hidden: 1Not hidden. Only the bare boolean true hides a page
keywords: "a, b"Dropped. keywords must be a YAML list
keywords: [foo, true, 3, null]["foo", "3"]. Booleans, nulls, and objects are filtered out
keywords: []Stays [], not undefined
title: 2024Survives as "2024"
icon: 2024 or image: 2024Dropped. Those two use a strict typeof === "string" check
Booleans, arrays, objects, null, ~, NaN, .inf in a text fieldDropped
A dropped titleFalls back to the slug
A dropped descriptionBecomes "", shipping a blank meta and OG description
date: "not-a-date"Survives verbatim. isoDateString passes any string through unvalidated, with no YYYY-MM-DD shape check

Blog

new Blog(config: BlogConfig)

BlogConfig extends Partial<SiteConfig>. The site attributes are flattened onto the config; there is no nested site key.

BlogConfig options

OptionTypeDefaultPurpose
contentDirstringrequiredDirectory holding one <slug>/ folder per post. A relative path resolves against process.cwd()
extensionstring".mdx"Post file extension, leading dot included
localestring"en-GB"BCP 47 locale for formatDate
localesLocaleConfig[][]Languages published. Unset means single-language
defaultLocalestringconfig.defaultLocale, else locales[0].code, else locale.split("-")[0], else "en"Unprefixed locale and x-default target
prefixDefaultLocalebooleanfalsePrefix the default locale too
trailingSlashbooleantrueEnd every built URL in a slash (/blog/post/). Must match the host app's next.config trailingSlash
basePathstring"/blog"Mount route, run through normalizeBasePath
siteUrlstringundefinedOrigin for metadataBase and absolute JSON-LD. Gates site
brandNamestringundefinedTitle suffix, og:siteName, publisher. Gates site
defaultAuthorstringundefinedAuthor fallback
descriptionstringfalls back to `The ${brandName} blog.`Index description
organizationIdstringundefined@id reference for the publisher
authorIdstringundefined@id reference for the author
websiteIdstringundefined@id reference for isPartOf

site is built only when siteUrl !== undefined && brandName !== undefined.

Public fields

FieldType
readonly localestring
readonly localesLocaleConfig[]
readonly defaultLocalestring
readonly prefixDefaultLocaleboolean
readonly trailingSlashboolean
readonly siteSiteConfig | undefined

Nothing else is exposed; contentDir and extension are not fields at all, only constructor arguments handed to the internal content store.

Methods

SignatureReturnsThrows
getPostSlugs()string[] of distinct slugs across all languagesDuplicatePostError
getPostRefs(){ slug: string; lang: string }[], every pairDuplicatePostError
getPost(slug, lang?)PostPostNotFoundError
getAllPosts(lang?)PostMeta[] for one language, sorted by date descendingDuplicatePostError
getTranslations(slug)string[], default firstDuplicatePostError
getAllCategories(lang?)string[], distinct and sortedDuplicatePostError
dateLocale(lang?)stringno
formatDate(iso, lang?)stringno
readingMinutes(post)number, whole minutes, floored at 1no
tableOfContents(post)TocEntry[]no
similarPosts(post, limit?)PostMeta[], same-language neighbours. limit defaults to 3DuplicatePostError
postMetadata(post)PageMetadataError when site is unset
overviewMetadata(lang?)PageMetadataError when site is unset
postJsonLd(post)JsonLdError when site is unset
overviewJsonLd(posts, lang?)JsonLdError when site is unset
sitemapEntries()SitemapEntry[]DuplicatePostError, Error when site is unset
rssFeed(lang?)string, one locale's complete RSS 2.0 document, newest post firstDuplicatePostError, Error when site is unset

requireSite() throws this message verbatim:

This Blog was created without a `site` config; pass `site` to use the SEO helpers.

That message is misleading. BlogConfig has no site key. The config is flat: pass siteUrl and brandName. Do not look for a site option; there is none.

Blog.dateLocale's JSDoc claims the fallback is the language code itself. The code returns the instance locale for an unconfigured language.

Post file resolution

entries() walks the immediate subdirectories of contentDir only; non-directories are skipped, so a stray notes.txt is ignored. Both <slug>/post<ext> and <slug>/<defaultLocale><ext> are checked, and having both is a DuplicatePostError. Each configured non-default locale is <slug>/<code><ext>. Any other file stem is ignored. In getPost, the locale-named file wins and post<ext> is the fallback.

There is an asymmetry: post.mdx and en.mdx together throw from entries(), and therefore from getPostSlugs, getAllPosts, and sitemapEntries, but getPost does not throw and silently returns en.mdx.

Drafts and hidden posts do not exist in the blog module. Every discovered post is published. PageMetadata.robots exists in the type, but no blog builder ever sets it.

similarPosts

similarPosts(current: PostMeta, all: PostMeta[], limit: number = 3): PostMeta[]
  1. Build a term vector for current; if empty, return [].
  2. Exclude current by slug.
  3. Score every candidate by cosine similarity, which returns a value in [0, 1].
  4. Drop every candidate whose score is 0.
  5. Sort by score descending, tie-broken by date descending.
  6. Slice to Math.max(0, limit).

Term weights: each keyword counts 3, the title 2, the description 1. Scoring is term-frequency only, with no idf. categories are not used in the ranking. Tokenization lowercases, splits on /[^\p{L}\p{N}]+/u, and keeps tokens of length 3 or more that are not among the 22 English-only stopwords: the, and, for, with, that, this, from, into, are, was, were, you, your, how, why, what, when, who, our, its, their, about.

Blog.similarPosts calls this with this.getAllPosts(post.meta.lang), so candidates are same-language only.

collectCategories

collectCategories(posts: PostMeta[]): string[]

Returns [...new Set(posts.flatMap((p) => p.categories ?? []))].sort(): distinct labels in default lexicographic order, not localeCompare.

PostMeta

FieldTypeRequiredNormalization
slugstringyesThe directory name
langstringyesFrom the filename stem
titlestringyesText coercion, else the slug
datestringyesisoDateString(data.date) ?? ""
descriptionstringyesText coercion, else ""
keywordsstring[]?noArray.isArray then map/filter, else undefined
categoriesstring[]?noSame shape as keywords
readingTimenumber?set in practicereadingMinutes(content)
authorstring?notypeof === "string", strict
authorImagestring?notypeof === "string", strict. The YAML key is author-image, kebab-case
imagestring?notypeof === "string", strict
updatedstring?noisoDateString(data.updated)

The camelCase authorImage field is populated from the kebab-case author-image YAML key:

---
title: "A post"
date: "2026-07-16"
author: "Ada Lovelace"
author-image: "/assets/authors/ada.jpg"
---

Normalization notes:

YAMLResult
categories: "news"Dropped. Only an array survives
author: 404undefined. author, authorImage, and image are string-only, unlike title: 404, which becomes "404"
date: "yesterday"Survives into meta.date, datePublished, and the sort. date is not validated

Post is { meta: PostMeta; content: string }, where content is the MDX body with the front matter stripped.

Errors

All four are declared in fs-free modules, so client code can instanceof them.

ErrorFieldsMessage
DocNotFoundErrorreadonly slug: string`No docs page found for slug "${slug}".`
DuplicateDocErrorslug, lang, files: [string, string]`Two files resolve to the same docs page (slug "${slug}", lang "${lang}"): "${a}" and "${b}".`
PostNotFoundErrorreadonly slug: string`No blog post found for slug "${slug}".`
DuplicatePostErrorslug, lang, files: [string, string]`Two files resolve to the same post (slug "${slug}", lang "${lang}"): "${a}" and "${b}".`
ErrorThrown when
DocNotFoundErrorNo candidate file exists for the slug, or the resolved path escapes contentDir
DuplicateDocErrorTwo files map to one ${slug}/${lang}; in practice <slug>/post.mdx and <slug>/<defaultLocale>.mdx both exist
PostNotFoundErrorNo candidate file exists, or the path escapes contentDir
DuplicatePostErrorTwo files map to one ${slug}/${lang}; in practice post.mdx and en.mdx both exist
import { Docs, DocNotFoundError } from "@daanvandenbergh/scribekit";

try {
    docs.getDoc(slug);
} catch (err) {
    if (err instanceof DocNotFoundError) {
        console.error(err.slug);
    }
}

Pure and fs-free. They import no next and no react, and none of them throw.

Signature
buildNavTree(metas: DocMeta[], opts: NavBuildOptions): NavTree
flattenNav(tree: NavTree): NavItem[]
adjacentFor(slug: string, flat: NavItem[]): Adjacent
breadcrumbFor(slug: string, tree: NavTree): Breadcrumb | undefined

NavBuildOptions:

FieldTypeRequired
basePathstring?no
defaultLocalestringyes
langstring?no
prefixDefaultLocaleboolean?no
trailingSlashboolean?no
tabsNavConfigEntry[]?no
groupsNavConfigEntry[]?no

An omitted basePath falls back to "/blog", because normalizeBasePath supplies that default. Docs always passes its own basePath, so this only affects direct callers of buildNavTree. trailingSlash is forwarded to localePath for every item href and defaults to true the same way, so a direct caller that omits it gets slash-terminated nav links.

lang is the language the tree is built for, and it selects which entry of a per-locale tab or group label / description map is used. It defaults to defaultLocale. Docs.getNavTree always passes it.

Sort rules

Bucketing: hidden pages are skipped. tabId = meta.tab ?? "" and groupId = meta.group ?? "".

Pages within a group compare on two keys:

  1. order, ascending, where an unset order becomes Number.POSITIVE_INFINITY, so unordered pages sort after every ordered one.
  2. On a tie, a.title.localeCompare(b.title), by title, not label and not slug.

Tabs and groups compare on three keys:

  1. configIndex, the position in the tabs or groups config array. An unlisted entry is -1, mapped to Infinity, so configured entries come first in config order and unconfigured entries follow.
  2. minOrder, the minimum order among descendant pages.
  3. firstSeen, discovery order from fs.readdirSync, as the final tie-break.

A tab or group label is the label given for that id in the tabs or groups config, falling back to the id itself. The implicit "" tab and "" group therefore get an empty-string label, which is falsy, so they contribute no breadcrumb segment.

adjacentFor walks the flattened tree, so prev/next crosses group and tab boundaries: the last page of one tab links straight into the first page of the next.

Known behaviour: when two pages in a group both lack order, the comparator computes Infinity - Infinity, which is NaN; Array#sort coerces that to 0. Those two pages keep their on-disk order rather than sorting alphabetically by title. The alphabetical tie-break applies only when both pages carry the same finite order. This is tracked in the repository's TODO.md.

SEO builders

Pure and fs-free. Each takes a SiteConfig whose siteUrl and brandName are required at the type level. At runtime, new URL(site.siteUrl) throws TypeError [ERR_INVALID_URL] on a missing or invalid siteUrl; the readable Error comes only from the Blog and Docs wrapper methods via requireSite().

Docs

SignatureEmits
buildDocMetadata(meta, site, translations = [meta.lang]): PageMetadatametadataBase, title: `${meta.title} | ${site.brandName}`, description, keywords, authors, alternates.canonical and languages, OpenGraph (type: "article", publishedTime, modifiedTime: meta.updated ?? meta.date, images, locale, alternateLocale), Twitter (card: "summary_large_image"). Sets robots: { index: false } iff meta.hidden. hreflang is undefined when translations.length <= 1
buildIndexMetadata(site, lang?, langs = []): PageMetadatatitle: `Docs | ${site.brandName}`, OG type: "website" with title: `${brandName} Docs`. No robots key; the index is never noindexed
docJsonLd(meta, site, translations = [meta.lang]): JsonLd@graph of a TechArticle and a BreadcrumbList (Home, Docs, the group name only, the page title). The tab never appears in JSON-LD breadcrumbs. When the page is translated, the TechArticle also carries workTranslation (an array, on the original-language version) or translationOfWork (on each translation)
indexJsonLd(items, site, lang?): JsonLdCollectionPage, BreadcrumbList, and an ItemList only when items.length > 0

The docs section name in breadcrumbs is the literal "Docs".

Blog

SignatureEmits
buildPostMetadata(meta, site, translations = [meta.lang]): PageMetadatatitle: `${meta.title} | ${site.brandName}`, alternates.canonical, OG type: "article", publishedTime: meta.date || undefined, modifiedTime: meta.updated ?? (meta.date || undefined), locale, twitter.card: "summary_large_image". Never sets robots
buildOverviewMetadata(site, lang?, langs = []): PageMetadatatitle: `Blog | ${site.brandName}`, OG type: "website" with title: `${brandName} Blog`. hreflang only when langs.length > 1
postJsonLd(meta, site, translations = [meta.lang]): JsonLd@graph of a BlogPosting and a BreadcrumbList of 3 items (Home, Blog, the title). A translated post also carries workTranslation / translationOfWork, as the docs builder does
overviewJsonLd(posts, site, lang?): JsonLdCollectionPage, a BreadcrumbList of 2 items, and an ItemList only when posts.length > 0
buildRssFeed(posts: PostMeta[], site: SiteConfig, lang?): stringOne locale's complete RSS 2.0 document, newest post first. This is what Blog.rssFeed() wraps
rssFeedPath(site: SiteConfig, lang: string): stringThe conventional mount path for that locale's feed: the locale's index path plus /rss.xml, so /blog/rss.xml by default, or /en/rss.xml under prefixDefaultLocale. site.trailingSlash never applies - the feed is a file, so its path stays bare

Shared helpers

SignatureBehaviour
readingMinutes(content: string, wpm = 200): numberMath.max(1, Math.round(words / wpm)). Floored at 1, rounded rather than ceiled
slugify(text: string): stringLowercases, strips everything that is not a letter, number, space, or hyphen, and collapses runs to -. Unicode letters are preserved. May return ""
tableOfContents(content: string): TocEntry[]One entry per ## or ### ATX heading in document order. Skips headings inside code fences. id = slugify(text), so duplicate heading text yields duplicate ids
formatDate(iso: string, locale = "en-GB"): string"" returns "". Parses at UTC midnight, so there is no timezone day-shift. An unparseable value is returned unchanged. Otherwise renders a long date, for example "28 June 2026"
isoDateString(value: unknown): string | undefinedA string is passed through unvalidated. A valid Date becomes YYYY-MM-DD. Anything else, including an invalid Date, becomes undefined
normalizeBasePath(basePath: string | undefined): stringbasePath ?? "/blog", then adds a leading / and strips a trailing /. "/" returns "", a root mount
buildSitemap(refs, site, translationsOf): SitemapEntry[]One entry per ref in the order given. alternates are emitted only when a slug has more than one translation. A siteUrl carrying a sub-path (https://user.github.io/repo) has that sub-path prepended, not discarded, so a project-site sitemap matches its canonicals

localePath

localePath(opts: {
    basePath?: string | undefined;
    defaultLocale: string;
    lang: string;
    slug?: string | undefined;
    prefixDefaultLocale?: boolean | undefined;
    trailingSlash?: boolean | undefined;
}): string

Builds the root-relative URL path for a page, or for a locale's index page when slug is omitted. It is the single source of truth shared by the SEO metadata and the rendered links.

ParameterTypeRequiredNotes
basePathstring?noThe section base path. Defaults to /blog
defaultLocalestringyesThe locale code served without a prefix
langstringyesThe target locale code
slugstring?noOmit for the locale's index URL
prefixDefaultLocaleboolean?noWhen true, the default locale is prefixed too
trailingSlashboolean?noDefaults to true. When false, the path is emitted bare

The locale prefix comes first, then the base path. The URL shape is /<lang><basePath>/<slug>/.

CaseResult
Default locale, prefixDefaultLocale false/blog/post/
Non-default locale/fr/blog/post/
Default locale, no slug/blog/
Non-default locale, no slug/fr/blog/
prefixDefaultLocale: true, default locale/en/blog/post/ and /en/blog/
prefixDefaultLocale: true, non-default locale/fr/blog/post/, prefixed either way
basePath: "/docs"/docs/quickstart/ and /fr/docs/quickstart/
basePath: "" (or "/")/quickstart/, /fr/quickstart/, and / for the index
trailingSlash: falseThe same paths without the final slash: /blog/post, /fr/blog, /quickstart. The site root stays /

Mounting a section at the site root - for a site that is only docs, so /getting-started/ beats a redundant /docs/getting-started/ - is what basePath: "" is for. The default locale's index is "/", never the empty string: an empty href resolves to the current page, so the index link would be dead. Scribekit's own docs site is mounted this way.

prefixDefaultLocale affects the default locale only. The slug is never escaped or slugified; it is the directory name verbatim. localePath takes plain serializable inputs rather than an instance, because it is called from a client component.

trailingSlash defaults to true here and must match the host app's next.config trailingSlash, whose own default is false - so an app that never set it needs either trailingSlash: true in next.config or trailingSlash: false on the instance. Every canonical, hreflang, sitemap entry, and nav link comes from this one function, so a mismatch is not one broken link: it points the whole surface at the URL form the host does not serve, and nothing throws.

The JSDoc on blog.ts, blog/types.ts, docs/types.ts, and docs/docs.ts claims a translation is served under <basePath>/<code>/, that is /blog/fr/post. That comment is wrong. The code and its tests produce /fr/blog/post.

Types

TypeShape
LocaleConfig{ code: string; label?: string; dateLocale?: string }
TocEntry{ depth: 2 | 3; text: string; id: string }
SiteConfigsiteUrl and brandName are the only two required fields; plus defaultAuthor?, basePath?, description?, defaultLocale?, prefixDefaultLocale?, trailingSlash?, organizationId?, authorId?, websiteId?
SitemapEntry{ url: string; alternates?: { languages?: Record<string, string> } }
PageMetadataA structural subset of Next's Metadata with no next type dependency: metadataBase?: URL, title?, description?, keywords?: string[], robots?: { index?: boolean; follow?: boolean }, authors?: { name: string }[], alternates?: { canonical?; languages? }, openGraph?, twitter?
JsonLdRecord<string, unknown>

Docs navigation types:

TypeShape
Doc{ meta: DocMeta; content: string }, where content is the MDX body with front matter stripped
NavLabelstring | Record<string, string>. A bare string is used for every language; a map is resolved at lang, then defaultLocale, then falls through to the id
NavConfigEntrystring | { id: string; label?: NavLabel; description?: NavLabel }. description is the one-sentence blurb the docs index topic card prints under its heading
NavItem{ slug, title, label, description?, icon?, href, lang, tab?, group?, order?, updated?, readingTime? }. label is meta.label ?? meta.title; href comes from localePath. DocsIndex reads description, updated, and readingTime
NavGroup{ id, label, description?, items }. id is "" for the ungrouped bucket
NavTab{ id, label, description?, groups }. id is "" for the implicit single tab
NavTree{ tabs, multiTab }, where multiTab === tabs.length > 1
BreadcrumbSegment{ label: string; href?: string }. breadcrumbFor never sets href, so the field exists but is always absent
Breadcrumb{ tab?, group?, title, segments }
Adjacent{ prev?: NavItem; next?: NavItem }

React components

Import from @daanvandenbergh/scribekit/react.

Blog

ComponentKindRequired props
BlogOverviewserverblog
BlogOverviewGridclientposts, categories, basePath, defaultLocale, prefixDefaultLocale, locale, pageSize, imgComponent, linkComponent
BlogPageserverblog, slug
BlogSidebarclienttoc

BlogOverview

PropTypeDefault
blogBlogrequired
langstringblog.defaultLocale
postsPostMeta[]blog.getAllPosts(resolvedLang)
basePathstringblog.site?.basePath ?? "/blog"
headerReactNodenone
imgComponentElementType"img"
linkComponentElementType"a"
emptyLabelstringtranslated
readMoreLabelstringtranslated
pageSizenumber9
searchPlaceholderstringtranslated
loadMoreLabelstringtranslated
allCategoriesLabelstringtranslated

BlogOverviewGrid

The interactive body rendered by BlogOverview: fuzzy search, category filters, and infinite scroll via IntersectionObserver. Category filters render only when categories.length > 1. Optional props are trailingSlash (defaults to true), lang, and the five label props.

BlogPage

PropTypeDefault
blogBlogrequired
slugstringrequired
langstringblog.defaultLocale
basePathstringblog.site?.basePath ?? "/blog"
componentsMDXRemoteProps["components"]merged over { h2, h3 }; the caller wins
mdxOptionsMDXRemoteProps["options"]merged with GFM
showBackLinkbooleantrue
backLabelstring"← Blog"
imgComponentElementType"img"
linkComponentElementType"a"
showSidebarbooleantrue
tocTitlestringtranslated (e.g. "On this page")
similarTitlestringtranslated (e.g. "Similar pages")
similarCountnumberpasses undefined through, so the backend default 3 applies
readingLabel(minutes: number) => stringtranslated

BlogSidebar

PropTypeDefault
tocTocEntry[]required
titlestring"On this page", hardcoded English
similarTitlestring"Similar pages", hardcoded English
similarReactNodenone

Docs

ComponentKindRequired props
DocsPageserverdocs, slug
DocsIndexserverdocs
DocsHeroservertitle
DocsTopicGridclienttopics
DocsRecentlyUpdatedserveritems
DocsNavbarclientnone
DocsNavbarButtonserver-safe, no hookschildren
DocsTabsclientnav
DocsSidebarclientnav
DocsLanguagePickerclientlocales, currentLang, defaultLocale
DocsTocclienttoc
DocsSearchProviderclientnav, children
DocsSearchButtonclientnone
DocsFeedbackclientquestion
DocsIconserver-safenone

DocsPage

PropTypeDefault
docsDocsrequired
slugstringrequired
langstringdocs.defaultLocale
componentsMDXRemoteProps["components"]merged over { h2, h3 }; the caller wins
mdxOptionsMDXRemoteProps["options"]merged with GFM
linkComponentElementType"a"
imgComponentElementType"img"
showBreadcrumbbooleantrue
showTocbooleantrue
showFeedbackbooleantrue
tocTitlestringtranslated (e.g. "On this page")
previousLabelstringtranslated (e.g. "Previous")
nextLabelstringtranslated (e.g. "Next")
feedbackQuestionstringtranslated (e.g. "Was this page helpful?")
feedbackYesLabelstringtranslated (e.g. "Yes")
feedbackNoLabelstringtranslated (e.g. "No")
feedbackThanksLabelstringtranslated (e.g. "Thanks for the feedback!")
onFeedback(vote: "yes" | "no") => voidnone
readingLabel(minutes: number) => stringtranslated
updatedLabel(date: string) => stringtranslated

DocsPage renders a hero <img> at width={1200} height={630} when meta.image is set.

DocsIndex

PropTypeDefault
docsDocsrequired
langstringdocs.defaultLocale
linkComponentElementType"a"
titlestringthe localized word "Documentation" (docsLabels(lang).title); the brand is not used
descriptionstringsite?.description
headerReactNodethe built-in hero
actionsDocsHeroAction[]none. The index does not invent a destination
heroVariantDocsHeroVariant"plain"
childrenReactNodenone. Rendered between the hero and the topic grid
showStatsbooleantrue. The hero's article-count / topic-count / newest-update row
filterbooleantrue. The topic grid's filter box
pagesPerTopicnumber3
recentCountnumber5. 0 drops the "Recently updated" section
accentsstring[]the built-in blue-to-coral cycle
renderIcon(name: string | undefined) => ReactNode<DocsIcon name={name} size={19} />

DocsIndex is a wrapper, not a monolith: it derives its data from the Docs instance and then renders DocsHero, your children, DocsTopicGrid, and DocsRecentlyUpdated. All three are exported separately, so an index that needs sections the kit does not ship can compose them by hand and still emit docs.indexJsonLd(lang) through the exported JsonLd component.

DocsHero

PropTypeDefault
titlestringrequired. The <h1>
descriptionstringomitted when unset
eyebrowstringomitted when unset
actionsDocsHeroAction[]omitted entirely when empty
statsDocsHeroStat[]omitted entirely when empty
variantDocsHeroVariant"plain"; "card" wraps it in the tinted gradient panel
linkComponentElementType"a"
childrenReactNodenone. Rendered inside the panel, after the stat row
classNamestringnone

DocsTopicGrid

The "Browse by topic" section. A client component, because its filter is local state - which is why every prop is plain data and icons arrive as already-rendered nodes rather than through a renderIcon callback. The filter searches each page's title and description plus its topic's title, across every page, including the ones the pagesPerTopic cap hides from the card.

PropTypeDefault
topicsDocsTopic[]required
labelsDocsTopicGridLabelsrequired
pagesPerTopicnumber3
filterbooleantrue
accentsstring[]["#2563eb", "#5b57f2", "#7c4fe0", "#9a55d0", "#b0487e", "#c25b4a", "#c4699e"]
linkComponentElementType"a"
headingIdstring"scribekit-docs-topics"
classNamestringnone

DocsRecentlyUpdated

A pure presentational server component. It does not sort or slice - it renders exactly the rows it is given, in the order given, and returns null when items is empty, so a corpus with no updated: dates simply has no section.

PropTypeDefault
itemsDocsRecentItem[]required
headingstringtranslated
linkComponentElementType"a"
headingIdstring"scribekit-docs-recent"
classNamestringnone

DocsNavbar

Must be rendered inside DocsSearchProvider.

PropTypeDefault
logoReactNodenone
logoSizenumber22
brandNamestringnone
docsTextstring | null"Docs". Pass null or "" to hide
homeHrefstring"/"
linkComponentElementType"a"
langstringnone
showSearchbooleantrue
searchPlaceholderstringtranslated
actionsReactNode[]none. Auto-hidden when the bar is too narrow - see below
languagePickerReactNodenone
showNavTogglebooleantrue. The drawer hamburger; pass false for a navbar with no sidebar

brandName and docsText are ReactNode, not string, so either can be a lockup rather than a word.

The navbar measures itself and drops the whole actions group when the bar is too narrow, marking itself data-cramped. Pass the same nodes to DocsSidebar's footer prop and the stylesheet reveals them there exactly when the bar gives up, so nothing becomes unreachable on a phone.

DocsNavbarButton

PropTypeDefault
childrenReactNoderequired
hrefstringnone. When set, renders a link; otherwise a <button>
onClick() => voidnone. Ignored when href is set
variantDocsNavbarButtonVariant"link". The type is "link" | "primary" | "secondary"
linkComponentElementType"a"
iconReactNodenone
targetstringnone
relstringnone
ariaLabelstringnone
classNamestringnone

DocsTabs

Returns null when !nav.multiTab, so a single-tab corpus renders no tab bar.

PropTypeDefault
navNavTreerequired
activePathstringnone
langstring"en"
linkComponentElementType"a"
labelstringtranslated

DocsSidebar

PropTypeDefault
navNavTreerequired
activePathstringnone
langstring"en"
linkComponentElementType"a"
labelstringtranslated
renderIcon(name: string | undefined) => ReactNode<DocsIcon name={name} /> at size 16
footerReactNodenone. Drawer-mode only: shown when DocsNavbar sets data-cramped
brandReactNodenone. Drawer-mode only: the drawer's brand lockup
showSearchbooleantrue. Drawer-mode only
searchPlaceholderstringtranslated

DocsLanguagePicker

Returns null when locales.length <= 1.

PropTypeDefault
localesLocaleConfig[]required
currentLangstringrequired
defaultLocalestringrequired
activePathstring""
basePathstring"/blog" via normalizeBasePath. Docs consumers must pass basePath="/docs"
prefixDefaultLocalebooleanfalse
trailingSlashbooleantrue
linkComponentElementType"a"
onSelect(code: string) => voidnone
renderFlag(code: string) => ReactNodei18nkit's localeFlag
langstringcurrentLang
changeLanguageLabelstringtranslated
headingLabelstringtranslated
classNamestringnone

DocsToc

Returns null when toc.length === 0.

PropTypeDefault
tocTocEntry[]required
titlestring"On this page", hardcoded English

DocsSearchProvider

Owns the ⌘K palette.

PropTypeDefault
navNavTreerequired
childrenReactNoderequired
langstring"en"
linkComponentElementType"a"
searchPlaceholderstringtranslated (e.g. "Search docs…")
searchEmptyLabelstringtranslated (e.g. "No results")
renderIcon(name: string | undefined) => ReactNode<DocsIcon name={name} />. Pass your own set here too, or the palette's result icons revert to the built-ins

DocsSearchButton

PropTypeDefault
placeholderstringtranslated (e.g. "Search docs…")
langstring"en"
classNamestringnone

DocsFeedback

PropTypeDefault
questionstringrequired
yesLabelstring"Yes", hardcoded English
noLabelstring"No", hardcoded English
thanksLabelstring"Thanks for the feedback!", hardcoded English
onVote(vote: "yes" | "no") => voidnone. Fires once, on the first answer

DocsIcon

PropTypeDefault
namestring?none. An unknown or missing name renders the document glyph
sizenumber?16
classNamestring?none

useDocsSearch

useDocsSearch(): { open: () => void }

Opens the palette owned by DocsSearchProvider. Call it from any client component beneath that provider.

Exported prop types

BlogOverviewProps, BlogOverviewGridProps, BlogPageProps, BlogSidebarProps, DocsPageProps, DocsIndexProps, DocsHeroProps, DocsHeroAction, DocsHeroStat, DocsHeroVariant, DocsTopicGridProps, DocsTopicGridLabels, DocsTopic, DocsTopicPage, DocsRecentlyUpdatedProps, DocsRecentItem, DocsNavbarProps, DocsNavbarButtonProps, DocsNavbarButtonVariant, DocsTabsProps, DocsSidebarProps, DocsLanguagePickerProps, DocsTocProps, DocsSearchProviderProps, DocsSearchButtonProps, DocsFeedbackProps, Language, BlogLabels, DocsLabels.

The /react subpath also exports the JsonLd component and the i18n core (ui, CATALOG, blogLabels, docsLabels, resolveLanguage).

The icon set

DocMeta.icon and DocsIcon's name accept these names:

book, rocket, workflow, phone, voice, calendar, globe, clock, plug, link, mail, grid, list, check, gear, code, sparkles, shield, document

Any other name silently renders the neutral document glyph. It does not error. A missing or undefined name also falls back to document. Lookup uses Object.prototype.hasOwnProperty.call(...), so icon: "toString" cannot resolve to an inherited member. Every glyph uses a 0 0 24 24 viewBox, fill="none", stroke="currentColor", strokeWidth={1.7}, round caps and joins, and aria-hidden="true". The path table itself is module-private and not exported.

Pass a renderIcon prop to DocsSidebar or DocsIndex to substitute your own icon set, in which case that set's names apply instead.

CSS custom properties

There is no :root block in the stylesheet. Every --scribekit-* token exists only as a var(--name, fallback) reference at each call site, so each "default" below is that inline fallback, not a declared value. You override a token by declaring the property yourself:

:root {
    --scribekit-primary: #6d5df6;
    --scribekit-ink: #101828;
}

62 distinct tokens are referenced. No dark mode ships. The stylesheet contains no prefers-color-scheme block and no .dark class; its nine media queries are responsive breakpoints at 1080px, 1081px, 640px, and 560px, plus two prefers-reduced-motion: reduce blocks. Dark theming is entirely your job, via token overrides.

One caveat before you try it: a handful of colours are written as literal rgba() with no token in front of them - the drawer backdrop and some DocsHero surfaces among them - so a token override alone will not reach every pixel.

Tokens with no single default

Six tokens are referenced with different fallbacks at different call sites, so they have no single default value. Set them explicitly if you want them consistent.

TokenValue to set
--scribekit-primary-subtle#eef2ff
--scribekit-ink-subtle#94a3b8
--scribekit-bg-subtle#f7f9fc
--scribekit-border#e5eaf0
--scribekit-border-strong#eaeef4
--scribekit-docs-tabbar-top0

Brand and accent

TokenDefault
--scribekit-primary#2563eb
--scribekit-primary-soft#c7d2fe
--scribekit-primary-subtleno single default; see above
--scribekit-primary-line#dbe4fe
--scribekit-violet#6d5df6
--scribekit-violet-subtle#f0eefe
--scribekit-violet-line#e4e0fc
--scribekit-success#15a34a
--scribekit-on-primary#ffffff
--scribekit-on-ink#ffffff

Text

TokenDefault
--scribekit-ink#0b1b36, the main text colour
--scribekit-ink-body#41506a
--scribekit-ink-muted#4a5568
--scribekit-ink-soft#5a6b82
--scribekit-ink-subtleno single default; see above
--scribekit-ink-hover#15264a
--scribekit-ink-faint#8189a8
--scribekit-nav-ink#475569
--scribekit-toc-ink#64748b

Surfaces and borders

TokenDefault
--scribekit-surface#ffffff
--scribekit-bg-subtleno single default; see above
--scribekit-borderno single default; see above
--scribekit-border-strongno single default; see above
--scribekit-navbar-bgrgba(255, 255, 255, 0.9)
--scribekit-docs-nav-bglinear-gradient(180deg, #fcfdfe, #fbfcfe)
--scribekit-docs-nav-hover#f1f4f9
--scribekit-scrimrgba(11, 27, 54, 0.32)

Shadows and focus

TokenDefault
--scribekit-card-shadow0 14px 34px -18px rgba(11, 27, 54, 0.35)
--scribekit-menu-shadow0 14px 40px -12px rgba(11, 27, 54, 0.28)
--scribekit-palette-shadow0 24px 70px rgba(11, 27, 54, 0.28)
--scribekit-focus-ringrgba(37, 99, 235, 0.18)

Typography

TokenDefault
--scribekit-font-sans"Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif
--scribekit-font-monoui-monospace, SFMono-Regular, Menlo, Consolas, monospace
--scribekit-font-displayinherit. Set it to give headings their own face

The docs index hero and topic cards

TokenDefault
--scribekit-topic-accent#5b57f2. Per-card, overridden by DocsTopicGrid's accents
--scribekit-hero-bglinear-gradient(160deg, #f7f8fe 0%, #f4f1fd 46%, #fbf3f8 100%)
--scribekit-hero-ctalinear-gradient(120deg, #2563eb, #6d5df6 62%, #8a54d8)
--scribekit-hero-rulelinear-gradient(90deg, #2563eb, #6d5df6 38%, #9a55d0 68%, #c4699e 100%)
--scribekit-hero-glowtwo radial-gradient washes; set none to blank them
--scribekit-hero-shadow0 20px 48px rgba(40, 30, 120, 0.06)

Code blocks

TokenDefault
--scribekit-code-bg#0b1b36
--scribekit-code-border#10203f
--scribekit-code-ink#c7d2fe

Layout widths

TokenDefault
--scribekit-overview-width1040px
--scribekit-post-width760px
--scribekit-doc-width760px
--scribekit-sidebar-width240px
--scribekit-docs-max-widthnone
--scribekit-docs-min-heightauto
--scribekit-docs-body-min-height60vh
--scribekit-docs-nav-width300px
--scribekit-docs-toc-width250px
--scribekit-docs-index-width1080px
--scribekit-docs-navbar-search-width440px

Sticky-chrome offsets

TokenDefaultNotes
--scribekit-docs-navbar-height64px
--scribekit-docs-tabbar-height47px
--scribekit-docs-navbar-top0
--scribekit-docs-tabbar-topno single defaultSet 0; see above
--scribekit-docs-chrome-topcomputedInternal. Do not set. Sibling-selector rules derive it from which chrome actually rendered, because DocsTabs renders nothing for a single tab. This is the only token the stylesheet itself assigns
--scribekit-docs-content-topnoneThe consumer override, and it always wins. Set it when the docs shell sits under an app-level header

i18n

Exported from @daanvandenbergh/scribekit/react.

ExportType
uiAn @daanvandenbergh/i18nkit I18n instance over 24 locales, with default: "en"
CATALOGThe copy catalog, 36 entries, each covering all 24 locales
blogLabels(lang: string) => BlogLabels
docsLabels(lang: string) => DocsLabels
resolveLanguage(raw: string | undefined) => Language

The i18n module imports no react, next, or server-only code, so both server and client components can use it.

ui covers the 24 official EU languages, keyed by primary BCP 47 subtag:

en, fr, de, es, it, pt, nl, pl, ro, el, sv, da, fi, cs, sk, hu, bg, hr, sl, et, lv, lt, ga, mt

resolveLanguage(raw) matches on the primary subtag, so "pt-BR" resolves to pt. An unknown or missing code resolves to "en".

Overriding copy

You do not mutate the catalog. Every label is a component prop that falls back to the resolved translation. Parameterized copy is overridden with a function.

<BlogPage
    blog={blog}
    slug={slug}
    tocTitle="Contents"
    readingLabel={(minutes) => `${minutes} min`}
/>

Default English strings

CATALOG keyDefault English
empty"No posts yet - check back soon."
readMore"Read more →"
searchPlaceholder"Search posts…"
loadMore"Load more"
allCategories"All"
back"← Blog"
onThisPage"On this page"
similarPages"Similar pages"
filterByCategory"Filter by category"
writtenBy"Written by"
publishedOn(d) => `Published ${d}`
readingTime(m) => `${m} min read`
docsPrevious"Previous"
docsNext"Next"
docsFeedbackQuestion"Was this page helpful?"
docsFeedbackYes"Yes"
docsFeedbackNo"No"
docsFeedbackThanks"Thanks for the feedback!"
docsSearchPlaceholder"Search docs…"
docsSearchEmpty"No results"
docsUpdatedOn(d) => `Updated ${d}`
docsTitle"Documentation"
docsLanguage"Language"
docsChangeLanguage"Change language"
docsOpenNav"Open navigation"
docsCloseNav"Close navigation"
docsBrowseByTopic"Browse by topic"
docsRecentlyUpdated"Recently updated"
docsFilterPages"Filter pages"
docsClearFilter"Clear filter"
docsPageCount(n) => `${n} page(s)`
docsArticleCount(n) => `${n} article(s)`
docsTopicCount(n) => `${n} topic(s)`
docsResultCount"{count} pages match “{query}”"
docsResultCountOne"1 page matches “{query}”"
docsNoMatches"Nothing matches “{query}”"

The last three are templates with {count} / {query} placeholders, not functions, because DocsTopicGrid is a client component and a function cannot cross the server/client boundary.

back is authored with ui.uniform("← Blog"), so it is one string for all 24 languages.

blogLabels(lang) returns BlogLabels: empty, readMore, searchPlaceholder, loadMore, allCategories, back, onThisPage, similarPages, filterByCategory, writtenBy, publishedLabel(date), readingLabel(minutes).

docsLabels(lang) returns DocsLabels, 26 members: title, onThisPage, previous, next, feedbackQuestion, feedbackYes, feedbackNo, feedbackThanks, searchPlaceholder, searchEmpty, language, changeLanguage, openNav, closeNav, browseByTopic, recentlyUpdated, filterPages, clearFilter, updatedLabel(date), readingLabel(minutes), pageCountLabel(count), articleCountLabel(count), topicCountLabel(count), resultCountTemplate, resultCountOneTemplate, noMatchesTemplate.

BlogSidebar's title and similarTitle, DocsToc's title, and DocsFeedback's yesLabel, noLabel, and thanksLabel hardcode English defaults instead of reading the catalog. Their parent components always pass the translated values, so this surfaces only when you render those components directly.

MDX

BlogPage and DocsPage both render the body through MdxContent, a package-internal wrapper that takes the same props MDXRemote does but calls serialize from next-mdx-remote/serialize and memoizes the compile in a 256-entry cache keyed on the options signature and the source text - so a repeated render of the same page skips the compile entirely. Only the MDXRemoteProps type comes from next-mdx-remote/rsc. The merge steps are internal; the observable contract is below.

remark-gfm is always on and cannot be disabled. It is prepended to options.mdxOptions.remarkPlugins on every render, so tables, strikethrough, task lists, and autolinks are always available. There is no prop, config option, or plugin ordering that turns it off. Your own plugins are preserved, never replaced; rehypePlugins, parseFrontmatter, and the rest pass through untouched.

Only h2 and h3 receive id attributes. Each id is the shared slugify applied to the heading's text content, and tableOfContents derives its ids the same way, so anchor ids always match ToC entry ids. Your components are spread after the injected h2 and h3, so the caller wins: overriding h2 or h3 drops the injected ids and breaks minimap jump links unless you set your own.

<DocsPage
    docs={docs}
    slug={slug}
    components={{ h2: MyHeading }}
/>

Levels other than h2 and h3 never receive an id, whether or not you override them.

imgComponent is not an MDX component. It styles the chrome images only, meaning the hero, card thumbnails, and the author avatar, and not <img> elements inside the MDX body. It defaults to "img"; pass next/image to use it. To restyle images inside the MDX body, use components={{ img: ... }}.

Was this page helpful?