Blog
Next.js boilerplate with MDX blog
Posts are files in your repo. Reviewed in a pull request, shipped with the code.
A file-based blog: posts are MDX files in content/blog, compiled at build time by @next/mdx. Ships validated frontmatter, a prerendered index and post pages, a per-post OG image, an RSS 2.0 feed and sitemap entries.
What MDX blog adds to the agent layer: 2 rules · 2 skills · 5 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick MDX blog?
Pick it if
Engineering blogs, changelogs, docs-adjacent writing and any site where the writers already have commit access. Best when posts should go through review with the code they describe. Also when a post needs to import a real component from the app.
Watch out for
- Publishing needs a deploy. A typo fix is a commit, a build and a rollout. Fine for engineers. Painful for a marketing team that expects a "publish" button.
- No editor. Writers work in a code editor with YAML frontmatter and Git. The only preview link is a deploy preview, and the only permissions are repository access.
Show 3 moreShow fewer
- No image pipeline. Images live in
public/, and you size and compress them. A hosted CMS gives you transforms and a CDN. - Content and code share a release cycle. Reverting a bad deploy reverts the posts published in it, and a content-only change still runs your whole build.
- Everything is yours. No API keys to rotate, no dataset to lose access to, no pricing change to absorb. The whole archive greps in one command.
What it costs
Free. No vendor, no seats, no API quota. The only cost is build time.
Prices change. Check with MDX blog before you commit.
registry/tested.yaml
Tested with MDX blog
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What MDX blog adds to the repo
Read straight from the blog-mdx manifest, so it is exactly what lands in your repo.
Environment variables
No environment variables. Nothing to sign up for, nothing to paste.
Dependencies
- @mdx-js/react^3
- @next/mdx^16
- gray-matter^4.0.3
- rehype-slug^6
- remark-frontmatter^5
- remark-gfm^4
- @mdx-js/loader^3dev
- @types/mdx^2dev
Scripts
- bun run blog:check
bunx tsx scripts/blog/check-posts.ts
Files it writes
13 files, at these exact paths.
content/2 files
blog/2 files
- hello-world.mdx
- writing-with-mdx.mdx
scripts/1 file
blog/1 file
- check-posts.ts
src/10 files
app/5 files
blog/4 files
[slug]/2 files
- opengraph-image.tsx
- page.tsx
rss.xml/1 file
- route.ts
- page.tsx
- sitemap.ts
components/2 files
blog/2 files
- callout.tsx
- post-card.tsx
lib/2 files
blog/2 files
- frontmatter.ts
- posts.ts
- mdx-components.tsx
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot nav-links
- @slot next-config-wrappers
- @slot verify-checks
The differentiator
What MDX blog teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (2)
Loaded when the agent opens a matching file.
Posts are validated content, not free-form files
Loads oncontent/**.claude/rules/blog-content.md
Every post needs frontmatter, and it has to validate
src/lib/blog/frontmatter.ts is the contract. A file that does not satisfy it
fails the build, not the review:
title: required, under 70 characters.description: required, 50 to 160 characters. It is the meta description, the blog-index excerpt and the RSS<description>. "TODO" is not a description, and neither is the first sentence of the post copied verbatim.date: required, quoted, ISOYYYY-MM-DD. Quote it or YAML turns it into aDateand the type check fails.tags: optional, lowercase and hyphenated (edge-runtime, notEdge Runtime). Reuse an existing tag before inventing one; checkallTags().author: optional string.draft: optional boolean.truehides the post from the index, the feed and the sitemap in production, while leaving it visible on the dev server.
Never read the clock to fill in date. The publication date is a fact about
the post that a human decides; a generated new Date() makes every rebuild
produce a different sitemap and feed.
The filename is the URL, and it is permanent
content/blog/scaling-postgres.mdx publishes at /blog/scaling-postgres.
- Filenames are lowercase and hyphenated. The loader rejects anything else.
- Once a post is deployed, its slug is immutable. Rewrite the title as often as
you like; renaming the file breaks every inbound link, every share and the
<guid>in the feed that readers' RSS clients use to tell posts apart. - If a slug genuinely has to change, it is a new file plus a redirect in
next.config.tsfrom the old path, never a silent rename. - One post per file. Do not add a second post's frontmatter to an existing file.
Body rules
- Start with the problem, not with a preamble about what the post will cover.
- Code blocks must be complete enough to paste and run. No
...inside a function body, no imports left implicit. - Use
##and###for structure.rehype-slugturns each into an anchor people link to, so treat a published heading as an address: reword it only when the content underneath genuinely changed. - Images go in
public/blog/<slug>/and are referenced as/blog/<slug>/x.png. Size and compress them before committing; there is no image pipeline here. - Only components registered in
src/mdx-components.tsxare available. Do not add animportof a client component to a post to get around that: register it once, deliberately, and use it everywhere.
Before you call a post done
bun run blog:check
bun run build
The first validates the frontmatter of every post at once; the second proves the MDX compiles and the OG image renders.
The blog compiles at build time and styles itself with tokens
Loads onsrc/app/blog/**src/app/sitemap.tssrc/lib/blog/**src/components/blog/**src/mdx-components.tsx.claude/rules/blog-rendering.md
No MDX compiler at runtime
- Post bodies are compiled by the bundler through the dynamic
import()insrc/app/blog/[slug]/page.tsx. Do not replace it with a runtime compiler (next-mdx-remote,@mdx-js/mdx'sevaluate,compile()in a route handler). Those pull the full MDX toolchain into the server bundle and turn a static page into per-request work. generateStaticParamsplusexport const dynamicParams = falseis what makes every post prerendered and every unknown slug a 404. Keep both.src/lib/blog/posts.tsusesnode:fsand is server-only. Importing it from a client component (anything with"use client"at the top, directly or transitively) breaks the build. Pass the data down as props instead.
Colours come from tokens, in the component map too
- Every element mapping in
src/mdx-components.tsxand every component insrc/components/blog/uses design-token utilities:text-ink,text-body,text-muted,bg-surface-card,border-hairline,rounded-md, thetext-title-*andtext-body-*type scale, and thep-*/gap-*spacing scale. - No Tailwind palette classes, no hex literals, no inline
stylecolours. A post must restyle itself when the design plugin changes, and a raw colour in the MDX map is the one thing that survives that change and looks wrong. - The single exception is
opengraph-image.tsx: Satori understands inline styles only. Keep it to two colours and keep them in step with the tokens insrc/app/globals.css.
Metadata is not optional
- Every post page exports
generateMetadatawith a title, the frontmatter description, a canonical URL and Open Graph fields. Missing metadata is a missing rich preview and a duplicate-content risk. - Canonical URLs, feed links and sitemap entries are built from
appUrl()(NEXT_PUBLIC_APP_URL). Never hardcode a domain and never build one from request headers: a canonical tag pointing at a preview deployment deindexes the real page. - The per-post
opengraph-image.tsxis what putsog:imagein the head. Do not also setopenGraph.imagesby hand; you will end up with two, and consumers pick the wrong one.
Feeds and sitemaps are deterministic
lastModifiedandpubDatecome from frontmatter (updated ?? date), never fromDate.now(). A sitemap whose dates change on every deploy trains crawlers to ignore its dates.- Escape everything that goes into XML.
escapeXmlin the feed route exists because one ampersand in a title makes the whole feed invalid. - Posts are sorted newest first with the slug as the tiebreaker, so two posts published on the same day do not swap places between builds.
Skills (2)
Invoked by name.
- /add-mdx-component
Add a component that posts can use without importing it, registered in src/mdx-components.tsx and styled with design tokens only.
.claude/skills/add-mdx-component/SKILL.md
- /new-post
Draft, validate and publish a new MDX post in content/blog, with frontmatter that passes the checker and a slug that will never change.
.claude/skills/new-post/SKILL.md
Solution docs (5)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- MDX in the App Router without shipping a compilernext-mdx-remote compiles every post on every request. Compile at build time with @next/mdx instead, and keep frontmatter with gray-matter.docs/solutions/blog-mdx/mdx-without-a-runtime.md
- OG images that render your font, not NotoImageResponse has no system fonts and silently falls back. Load a static TTF from disk, and know why woff2 and variable fonts fail.docs/solutions/blog-mdx/og-images-that-render-your-font.md
- An RSS feed that actually validatesUnescaped ampersands, ISO dates and a missing atom:link are why feed readers reject your feed. Generate it as a static route with escaped text.docs/solutions/blog-mdx/rss-that-validates.md
- A sitemap and canonical URLs that do not fight each otherlastModified from new Date() teaches crawlers to ignore your dates, and a canonical built from request headers points at your preview deploys.docs/solutions/blog-mdx/sitemap-and-canonical-urls.md
- Syntax highlighting without a 300kb bundlePrism and highlight.js in a client component ship a parser to every reader. Highlight at build time with a rehype plugin and ship CSS instead.docs/solutions/blog-mdx/syntax-highlighting-without-a-huge-bundle.md
How it fits
What MDX blog needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
Nothing. MDX blog stands on its own.
Pairs well with
Nothing extra. Add any tested battery alongside MDX blog.
Compared with the alternatives
Build a repo with MDX blog
Free and MIT. The builder opens with MDX blog picked. You download the zip right away, and we email you the link too.
Presets
Presets that already include MDX blog
A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.