Skip to content

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 more
  • 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.

Database
NeonSupabase
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, ISO YYYY-MM-DD. Quote it or YAML turns it into a Date and the type check fails.
  • tags: optional, lowercase and hyphenated (edge-runtime, not Edge Runtime). Reuse an existing tag before inventing one; check allTags().
  • author: optional string.
  • draft: optional boolean. true hides 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.ts from 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-slug turns 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.tsx are available. Do not add an import of 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() in src/app/blog/[slug]/page.tsx. Do not replace it with a runtime compiler (next-mdx-remote, @mdx-js/mdx's evaluate, compile() in a route handler). Those pull the full MDX toolchain into the server bundle and turn a static page into per-request work.
  • generateStaticParams plus export const dynamicParams = false is what makes every post prerendered and every unknown slug a 404. Keep both.
  • src/lib/blog/posts.ts uses node:fs and 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.tsx and every component in src/components/blog/ uses design-token utilities: text-ink, text-body, text-muted, bg-surface-card, border-hairline, rounded-md, the text-title-* and text-body-* type scale, and the p-*/gap-* spacing scale.
  • No Tailwind palette classes, no hex literals, no inline style colours. 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 in src/app/globals.css.
Metadata is not optional
  • Every post page exports generateMetadata with 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.tsx is what puts og:image in the head. Do not also set openGraph.images by hand; you will end up with two, and consumers pick the wrong one.
Feeds and sitemaps are deterministic
  • lastModified and pubDate come from frontmatter (updated ?? date), never from Date.now(). A sitemap whose dates change on every deploy trains crawlers to ignore its dates.
  • Escape everything that goes into XML. escapeXml in 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

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.

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.