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 aContentCollection("blog" | "changelog" | "legal"):getAllEntries(collection)— allpublishedentries, newest-date-first.getEntryBySlug(collection, slug)— one entry with its raw MDXcontent, ornullif missing/unpublished.getAllSlugs(collection)— forgenerateStaticParams.
This started as a blog-only
lib/blog.tsand 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'scontentstring vianext-mdx-remote/rsc(a Server Component, no client JS shipped for the content itself) withremark-gfm(tables, strikethrough),rehype-slug(heading anchors), andrehype-pretty-code(Shiki syntax highlighting, no extra client bundle). Styling is hand-rolled Tailwind classes per element — no@tailwindcss/typographydependency, and not the vendoredTable/etc. components either (next-mdx-remotemaps 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) andapp/(marketing)/blog/[slug]/page.tsx(post —generateStaticParams,generateMetadatawith OpenGraph/canonical, and a JSON-LDArticlescript 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 separatelegal.md/changelog.md— seedocs/README.md's index). -
app/sitemap.ts— includes every published entry from all three collections;app/robots.tspoints at it and excludes authenticated routes (/dashboard,/admin,/billing,/settings,/api). -
app/layout.tsx'smetadata.metadataBaseis set fromNEXT_PUBLIC_APP_URLso 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/orcase-studies/) — add the value toContentCollectioninlib/content.ts, createcontent/<name>/, and add a route that callsgetAllEntries/getEntryBySlugwith that collection name. No changes tolib/content.ts's logic are needed. - MDX components — add JSX support (callouts, embeds) by extending the
componentsmap incomponents/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).
Legal pages + cookie consent
content/legal/{privacy,terms}.mdx— same MDX pipeline as the blog, rendered byapp/(marketing)/legal/[slug]/page.tsx(generateStaticParamsovergetAllSlugs("legal")). Frontmatterdateis 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 aconsent=grantedcookie (client-side, 1 year) and the analytics script tag renders; "Decline" writesconsent=deniedand it never does. The choice is remembered — the banner doesn't reappear once either button is clicked.- How to remove it: delete
content/legal/, thelegal/[slug]route,components/marketing/consent-banner.tsx, its mount inapp/layout.tsx, and the legal footer links inapp/(marketing)/layout.tsx.
Changelog + newsletter
content/changelog/*.mdx— same frontmatter shape as blog posts (title/description/date), rendered as a single timeline page atapp/(marketing)/changelog/page.tsx(not one route per entry — the whole collection renders on one page, each entry's heading getting anidanchor from its slug for deep-linking, e.g./changelog#2026-08-phase-7).- Newsletter capture —
lib/db/schema/app.ts'snewsletterSubscriberstable (email, unique,subscribedAt) +lib/actions/newsletter.ts'ssubscribeToNewsletterserver action (rate-limited, zod-validated, duplicate-email-safe viaonConflictDoNothing). Mounted ascomponents/marketing/newsletter-form.tsxin the marketing footer and at the bottom of the blog index. WhenRESEND_API_KEYandRESEND_AUDIENCE_IDare both set, the subscriber is also added to a Resend Audience (lib/email.ts'saddToAudience— best-effort, same non-fatal-if-unconfigured contract as the rest oflib/email.ts); with either unset, the subscription is still recorded in Postgres, just not synced anywhere external. - How to remove it: delete
content/changelog/, thechangelog/route,lib/actions/newsletter.ts,components/marketing/newsletter-form.tsx, its two mount points, thenewsletterSubscriberstable (regenerate a migration), and the "Changelog" footer link.