Content
Write docs and blog posts in MDX with Fumadocs, publish release notes with an RSS feed, and edit the public status page.
Launch Now ships four content surfaces out of the box: a documentation site at /docs, a blog at /blog, a changelog at /changelog (with an RSS feed), and a status page at /status. Docs and blog posts are MDX files rendered by Fumadocs. The changelog and the status page are plain TypeScript data files, so you edit them like any other code.
How it works
| Surface | Content lives in | Rendered by |
|---|---|---|
| Docs | content/docs/**/*.mdx | app/docs/layout.tsx, app/docs/[[...slug]]/page.tsx |
| Blog | content/blog/**/*.mdx | app/blog/page.tsx, app/blog/[...slug]/page.tsx |
| Changelog | features/changelog/changelog-data.ts | app/changelog/page.tsx, app/changelog/rss.xml/route.ts |
| Status | features/status/status-data.ts | app/status/page.tsx |
Supporting files:
lib/source.ts: the Fumadocs loader for the docs collection (base URL/docs).lib/blog.ts: the Fumadocs loader for the blog collection (base URL/blog) and its frontmatter schema.lib/layout.shared.ts: shared layout options for the docs (nav title, header links).components/mdx.tsx: the MDX component map used by docs and blog pages.app/api/search/route.ts: the Orama search endpoint for the docs.next.config.mjs: wraps the Next.js config withcreateMDX()fromfumadocs-mdx/next.
All four pages are listed in app/sitemap.ts, and every docs page and blog post is added to the sitemap automatically. See SEO.
Docs and blog with Fumadocs
Collections
There is no source.config.ts file. Each collection is declared next to its loader with defineDocs from fumadocs-mdx/macro, and next.config.mjs enables MDX compilation:
import { defineDocs } from "fumadocs-mdx/macro"
import { loader } from "fumadocs-core/source"
const docs = defineDocs({
dir: "content/docs",
})
export const source = loader({
baseUrl: "/docs",
source: docs.toFumadocsSource(),
})import { createMDX } from "fumadocs-mdx/next"
// ...
const withMDX = createMDX()
export default withMDX(config)The blog collection extends the default Fumadocs page schema with two optional fields, date and authors:
const blogSchema = pageSchema.extend({
date: z.string().optional(),
authors: z.array(z.string()).optional(),
})
const blogCollection = defineDocs({
dir: "content/blog",
docs: {
schema: blogSchema,
},
})
export const blog = loader({
baseUrl: "/blog",
source: blogCollection.toFumadocsSource(),
})
export type BlogPost = (typeof blog)["$inferPage"]Add a docs page
Create the file
The file path becomes the URL: content/docs/guides/setup.mdx is served at /docs/guides/setup, and content/docs/index.mdx is served at /docs.
---
title: Setup
description: Get the app running locally.
---
## Install dependencies
Run `pnpm install`.Check the page
Start the dev server with pnpm dev and open the URL. The page title and description come from the frontmatter and are also used for the page metadata (generateMetadata in app/docs/[[...slug]]/page.tsx). The sidebar is built from source.getPageTree(), so the new page appears there without extra configuration.
Docs pages are statically generated with generateStaticParams(), and links between pages can use relative file paths thanks to createRelativeLink(source, page).
Write a blog post
Create an MDX file under content/blog/. Add a date (used for sorting and display) and an optional authors list:
---
title: Hello, Pulse — the AI-ready SaaS boilerplate
description: We built the boilerplate we wished we had — auth, billing, admin, and emails already wired so you can ship in days, not months.
date: "2026-08-03"
authors: ["Antonio R."]
---app/blog/page.tsx lists every post, newest first, sorting on date (posts without a date go last). Dates are formatted as MMMM d, yyyy with date-fns. The listing page is wrapped in MarketingShell, so it shares the nav and footer of the landing page. Each post is rendered by app/blog/[...slug]/page.tsx with the Fumadocs DocsPage components and gets its title and description as metadata.
Good to know: The blog index has hardcoded copy (the "Auth APIs, documented" heading and the metadata description). Update both in app/blog/page.tsx when you rename the product.
MDX components
components/mdx.tsx exports getMDXComponents(), which merges the default Fumadocs MDX components (callouts, cards, code blocks, tabs and so on) with any overrides you pass. Add your own components there to make them available in every docs page and blog post without an import:
import defaultMdxComponents from "fumadocs-ui/mdx"
import type { MDXComponents } from "mdx/types"
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
...components,
} satisfies MDXComponents
}
export const useMDXComponents = getMDXComponentsDocs layout
app/docs/layout.tsx renders the Fumadocs DocsLayout inside a RootProvider with theme={{ enabled: false }}: the theme comes from the app's own ThemeProvider instead of Fumadocs'. The layout is wrapped in marketing marketing-plain classes, and app/globals.css maps the Fumadocs --color-fd-* tokens to the marketing palette, so /docs sits on the same warm-paper surface as the landing page. See Theming.
The top navigation comes from baseOptions():
export function baseOptions(): BaseLayoutProps {
return {
nav: {
title: siteConfig.name,
},
links: [
{ text: "Docs", url: "/docs", active: "nested-url" },
{ text: "Blog", url: "/blog", active: "nested-url" },
],
searchToggle: {
enabled: false,
},
}
}Search
app/api/search/route.ts exposes a GET handler built with createFromSource from fumadocs-core/search/server. It indexes the docs collection (not the blog) with the English Orama tokenizer:
import { source } from "@/lib/source"
import { createFromSource } from "fumadocs-core/search/server"
export const { GET } = createFromSource(source, {
language: "english",
})The search toggle in the docs header is disabled in lib/layout.shared.ts (searchToggle.enabled: false). Set it to true to show the Fumadocs search dialog, which queries /api/search.
Changelog
The changelog is a typed array in features/changelog/changelog-data.ts, newest first. /changelog renders it and /changelog/rss.xml turns the same data into an RSS 2.0 feed.
Good to know: The root CHANGELOG.md is generated by release-it (see pnpm release). The public /changelog page does not read it. Write the user-facing entries in changelog-data.ts yourself.
Entry format
export const changelogCategories = [
"new",
"improved",
"fixed",
"security",
] as const
export type ChangelogEntry = {
slug: string
version: string
/** YYYY-MM-DD. */
date: string
title: string
summary: string
groups: ChangelogGroup[]
readMore?: { href: string; label: string }
}| Field | Notes |
|---|---|
slug | Unique. Used as the entry's anchor id (/changelog#slug) and as the RSS item link. |
version | Shown next to the title. The RSS title is v{version}: {title}. |
date | YYYY-MM-DD, interpreted as UTC midnight. |
summary | One or two sentences shown under the title and in the feed. |
groups | One group per category (new, improved, fixed, security), each with a list of items. |
readMore | Optional link rendered at the bottom of the entry. |
Publish a release
Add an object at the top of changelogEntries:
export const changelogEntries: ChangelogEntry[] = [
{
slug: "csv-export",
version: "2.9.0",
date: "2026-10-02",
title: "Export any table to CSV",
summary: "Every table in the dashboard now has an export button.",
groups: [
{ category: "new", items: ["CSV export on every table"] },
{ category: "fixed", items: ["Dates in exports use your timezone"] },
],
},
// ...
]The first entry in the array is flagged as the latest one on the page, and its date becomes the feed's lastBuildDate.
Filtering
The category filter (features/changelog/changelog-filter.tsx) renders real links to /changelog?category=new, ?category=fixed and so on, so filtered views are shareable and work before hydration. parseCategory() ignores unknown values and falls back to "All". An entry is shown when at least one of its groups matches the category.
RSS feed
app/changelog/rss.xml/route.ts is a force-static route handler. It builds absolute links from siteConfig.url, escapes every string, and puts each entry's summary and grouped items in the item description. The changelog page advertises the feed through alternates.types:
export const metadata: Metadata = {
...seo,
alternates: {
...seo.alternates,
types: { "application/rss+xml": "/changelog/rss.xml" },
},
}NEXT_PUBLIC_APP_URL in production. siteConfig.url falls back to http://localhost:3000, and the feed is generated at build time, so a missing value ships localhost links to feed readers.The changelog is also linked from the marketing footer, the app sidebar and the command menu.
Status page
/status is a public status page with an overall banner, scheduled maintenance, 90-day uptime bars per component, and a 14-day incident history. It ships with mock data: nothing is fetched from a monitoring service.
| File | Role |
|---|---|
features/status/status-data.ts | Components, scripted incidents, maintenance windows, uptime generation |
features/status/status-meta.ts | Labels and colors per status, severity and incident stage |
features/status/status-sections.tsx | StatusBanner, ComponentList, MaintenanceCard, IncidentHistory |
features/status/uptime-bar.tsx | The per-day uptime bar with tooltips |
features/status/subscribe-dialog.tsx | "Subscribe to updates" dialog |
How the mock data works
Everything is derived from a fixed anchor, STATUS_UPDATED_AT, and a seeded random generator, so the server and the client compute the same history and hydration never mismatches:
/** "Now" for the mock. Fixed, so the page renders the same everywhere. */
export const STATUS_UPDATED_AT = "2026-09-23T09:41:00Z"
export const UPTIME_WINDOW_DAYS = 90
export const INCIDENT_HISTORY_DAYS = 14componentslists the monitored services (api,dashboard,auth,webhooks,email,database,storage) with a currentstatus.scriptedIncidentsholds incidents placeddaysAgodays before the anchor; each one sets that day's uptime on the affected components and appears in the incident history.- Older days get occasional random blips outside the 14-day history window.
scheduledMaintenancelists upcoming windows shown under the banner.getOverallStatus()picks the worst current component status to drive the banner headline.
Statuses are operational, degraded, partial_outage, major_outage and maintenance. Incident severities are minor, major, critical and maintenance, and each update has a stage: investigating, identified, monitoring or resolved.
Edit the page
- Rename or add components in the
componentsarray, and update theStatusComponentIdunion to match. - Change a component's current
statusto update the banner. - Add an incident to
scriptedIncidentswith itsdaysAgo,uptime,severity,componentIdsandupdates(newest first). - Edit
scheduledMaintenancefor upcoming windows.
Good to know: The subscribe dialog is a mock too. Submitting it only shows a confirmation toast; no request is sent. Wire it to your email provider or to the newsletter if you want real subscriptions.
Connect real data
To show live data, replace the exported statusComponents, incidentHistory and scheduledMaintenance with values fetched from your monitoring provider, keeping the same types so the section components keep working. Remove the fixed STATUS_UPDATED_AT anchor and pass the real last-updated time to StatusBanner.