Launch NowDocs

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

SurfaceContent lives inRendered by
Docscontent/docs/**/*.mdxapp/docs/layout.tsx, app/docs/[[...slug]]/page.tsx
Blogcontent/blog/**/*.mdxapp/blog/page.tsx, app/blog/[...slug]/page.tsx
Changelogfeatures/changelog/changelog-data.tsapp/changelog/page.tsx, app/changelog/rss.xml/route.ts
Statusfeatures/status/status-data.tsapp/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 with createMDX() from fumadocs-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:

lib/source.ts
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(),
})
next.config.mjs
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:

lib/blog.ts
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.

content/docs/guides/setup.mdx
---
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:

content/blog/index.mdx
---
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:

components/mdx.tsx
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 = getMDXComponents

Docs 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():

lib/layout.shared.ts
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,
    },
  }
}

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:

app/api/search/route.ts
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

features/changelog/changelog-data.ts
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 }
}
FieldNotes
slugUnique. Used as the entry's anchor id (/changelog#slug) and as the RSS item link.
versionShown next to the title. The RSS title is v{version}: {title}.
dateYYYY-MM-DD, interpreted as UTC midnight.
summaryOne or two sentences shown under the title and in the feed.
groupsOne group per category (new, improved, fixed, security), each with a list of items.
readMoreOptional link rendered at the bottom of the entry.

Publish a release

Add an object at the top of changelogEntries:

features/changelog/changelog-data.ts
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:

app/changelog/page.tsx
export const metadata: Metadata = {
  ...seo,
  alternates: {
    ...seo.alternates,
    types: { "application/rss+xml": "/changelog/rss.xml" },
  },
}
Set 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.

FileRole
features/status/status-data.tsComponents, scripted incidents, maintenance windows, uptime generation
features/status/status-meta.tsLabels and colors per status, severity and incident stage
features/status/status-sections.tsxStatusBanner, ComponentList, MaintenanceCard, IncidentHistory
features/status/uptime-bar.tsxThe 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:

features/status/status-data.ts
/** "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 = 14
  • components lists the monitored services (api, dashboard, auth, webhooks, email, database, storage) with a current status.
  • scriptedIncidents holds incidents placed daysAgo days 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.
  • scheduledMaintenance lists 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 components array, and update the StatusComponentId union to match.
  • Change a component's current status to update the banner.
  • Add an incident to scriptedIncidents with its daysAgo, uptime, severity, componentIds and updates (newest first).
  • Edit scheduledMaintenance for 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.

Next steps