Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

Content (Blog / Changelog / Legal)

Content (Blog / Changelog / Legal)

What it does

A file-based MDX content system shared by three collections: the blog (/blog), the changelog (/changelog), and legal pages (/legal/[slug]). No CMS, no database table — content lives in the repo as .mdx files with frontmatter, versioned and reviewed like code.

How to configure it

No env vars required. NEXT_PUBLIC_APP_URL (already used elsewhere — see setup.md) feeds the canonical URLs in blog post metadata and app/sitemap.ts.

How it works

  • content/{blog,changelog,legal}/*.mdx — one file per entry. Frontmatter:

    ---
    title: "Post title"
    description: "One-sentence summary, used as the meta description."
    date: "2026-06-02"        # publish date (blog/changelog) or "last updated" (legal)
    tags: ["nextjs", "stripe"] # optional
    published: true            # optional, defaults to true — set false to draft
    ---
  • lib/content.ts — the reader, generic over a ContentCollection ("blog" | "changelog" | "legal"):

    • getAllEntries(collection) — all published entries, newest-date-first.
    • getEntryBySlug(collection, slug) — one entry with its raw MDX content, or null if missing/unpublished.
    • getAllSlugs(collection) — for generateStaticParams.

    This started as a blog-only lib/blog.ts and was generalized here (Phase 7.4) so a fourth collection is "add a directory + a page", not "copy this file a third time".

  • components/mdx/mdx-content.tsx — renders an entry's content string via next-mdx-remote/rsc (a Server Component, no client JS shipped for the content itself) with remark-gfm (tables, strikethrough), rehype-slug (heading anchors), and rehype-pretty-code (Shiki syntax highlighting, no extra client bundle). Styling is hand-rolled Tailwind classes per element — no @tailwindcss/typography dependency, and not the vendored Table/etc. components either (next-mdx-remote maps raw HTML tag names to a component map, and can't be told to render a component with sub-parts in their place) — but every color is a design-system token (text-primary, border-border, bg-muted, …), not a raw value. See ui.md.

  • Blog: app/(marketing)/blog/page.tsx (index) and app/(marketing)/blog/[slug]/page.tsx (post — generateStaticParams, generateMetadata with OpenGraph/canonical, and a JSON-LD Article script tag for rich snippets).

  • Changelog: app/(marketing)/changelog/page.tsx — see Changelog + newsletter below.

  • Legal: app/(marketing)/legal/[slug]/page.tsx — see legal.md section below (this doc covers all three collections; there's no separate legal.md/changelog.md — see docs/README.md's index).

  • app/sitemap.ts — includes every published entry from all three collections; app/robots.ts points at it and excludes authenticated routes (/dashboard, /admin, /billing, /settings, /api).

  • app/layout.tsx's metadata.metadataBase is set from NEXT_PUBLIC_APP_URL so relative OpenGraph image paths (see Dynamic OG images below) resolve correctly.

How to extend it

  • Add a blog post — see recipes/add-a-blog-post.md.
  • Add a fourth collection (e.g. docs/ or case-studies/) — add the value to ContentCollection in lib/content.ts, create content/<name>/, and add a route that calls getAllEntries/ getEntryBySlug with that collection name. No changes to lib/content.ts's logic are needed.
  • MDX components — add JSX support (callouts, embeds) by extending the components map in components/mdx/mdx-content.tsx; every collection picks it up automatically since they all render through <MdxContent>.

How to remove it

Delete lib/content.ts, components/mdx/, content/, the blog/changelog/ legal routes under app/(marketing)/, the MDX deps from package.json (next-mdx-remote, gray-matter, remark-gfm, rehype-slug, rehype-pretty-code, shiki), the "Blog"/"Changelog"/legal footer links in app/(marketing)/layout.tsx, and the blog/changelog/legal entries from app/sitemap.ts. app/robots.ts and metadataBase are independently useful and don't need to go with it.


Dynamic OG images

app/opengraph-image.tsx (site-wide default) and app/(marketing)/blog/[slug]/opengraph-image.tsx (per-post: title, date, first tag) render branded PNGs at request time via Next's ImageResponse, picked up automatically for social-share previews — no manual <meta property="og:image"> needed, Next wires the route convention into each page's metadata itself. Route segments below blog/[slug]/ inherit the page's dynamic params, so the post's own title/date/tags render into the image without extra data-fetching wiring.

How to remove it: delete both opengraph-image.tsx files; Next falls back to no image (or the OpenGraph fields already set at each generateMetadata call, if any).

  • content/legal/{privacy,terms}.mdx — same MDX pipeline as the blog, rendered by app/(marketing)/legal/[slug]/page.tsx (generateStaticParams over getAllSlugs("legal")). Frontmatter date is read as "last updated" in the page header.
  • components/marketing/consent-banner.tsx — a minimal cookie-consent banner. It renders only when an analytics provider is configured (NEXT_PUBLIC_ANALYTICS_PROVIDER + NEXT_PUBLIC_ANALYTICS_ID — see setup.md); with no analytics configured, there's nothing to consent to, so nothing renders. "Accept" writes a consent=granted cookie (client-side, 1 year) and the analytics script tag renders; "Decline" writes consent=denied and it never does. The choice is remembered — the banner doesn't reappear once either button is clicked.
  • How to remove it: delete content/legal/, the legal/[slug] route, components/marketing/consent-banner.tsx, its mount in app/layout.tsx, and the legal footer links in app/(marketing)/layout.tsx.

Changelog + newsletter

  • content/changelog/*.mdx — same frontmatter shape as blog posts (title/description/date), rendered as a single timeline page at app/(marketing)/changelog/page.tsx (not one route per entry — the whole collection renders on one page, each entry's heading getting an id anchor from its slug for deep-linking, e.g. /changelog#2026-08-phase-7).
  • Newsletter capture — lib/db/schema/app.ts's newsletterSubscribers table (email, unique, subscribedAt) + lib/actions/newsletter.ts's subscribeToNewsletter server action (rate-limited, zod-validated, duplicate-email-safe via onConflictDoNothing). Mounted as components/marketing/newsletter-form.tsx in the marketing footer and at the bottom of the blog index. When RESEND_API_KEY and RESEND_AUDIENCE_ID are both set, the subscriber is also added to a Resend Audience (lib/email.ts's addToAudience — best-effort, same non-fatal-if-unconfigured contract as the rest of lib/email.ts); with either unset, the subscription is still recorded in Postgres, just not synced anywhere external.
  • How to remove it: delete content/changelog/, the changelog/ route, lib/actions/newsletter.ts, components/marketing/newsletter-form.tsx, its two mount points, the newsletterSubscribers table (regenerate a migration), and the "Changelog" footer link.