Skip to content

feat: serve each page's markdown at its own URL plus .md - #640

Merged
abernier merged 4 commits into
mainfrom
claude/md-suffix-urls
Oct 1, 2026
Merged

abernier merged 4 commits into
mainfrom
claude/md-suffix-urls

Conversation

@abernier

@abernier abernier commented Oct 1, 2026

Copy link
Copy Markdown
Member

Stacked on #639.

The markdown of a page moves from /md/<path>.md to the page URL plus .md, e.g. /getting-started/introduction.md. The "Copy Page" and "View as Markdown" actions use the new URL.

Why /md existed

The markdown is served by a route handler, and a route handler cannot share a segment with [...slug]/page.tsx (nor sit in a sibling catch-all with another slug name). #615 therefore gave it its own /md prefix. The handler stays where it is, src/app/md/[...slug]/route.ts (with dynamicParams = false from #639), but /md is now an internal path, exposed differently in each mode:

Server mode (Vercel, next start)

  • A rewrite in next.config.mjs maps /:path+.md to /md/:path+.md. A default (afterFiles) rewrite is checked after public files and static pages but before dynamic routes, so the [...slug] catch-all never sees these paths. basePath is prefixed onto both sides by Next. :path+ rather than :path*, so /.md is not rewritten to /md.md.
  • The rewrite is only set when not exporting: with output: 'export' Next would warn about it.
  • Old URLs: a permanent redirect from /md/:path+.md to /:path+.md. Redirects only apply to the incoming request, never to a rewrite's destination, so it cannot loop with the rewrite (checked: one 308, then 200).
  • Unknown /<path>.md is a 404, with MDX unset at runtime: the rewritten path hits dynamicParams = false.

Static export (OUTPUT=export, CLI --format website, GitHub Pages)

There are no rewrites in a static export, so the files are moved instead: the export writes md/<path>.md, and both export entry points move them beside the pages, at <path>.md, leaving no md/ behind:

  • next-build.sh (pnpm run build, used by start.sh and Playwright), in ${DIST_DIR:-out};
  • src/cli/website.ts (npx @pmndrs/docs build ... --format website, used by the reusable build.yml), when copying the export to the out dir.

The base path is not part of the exported file layout, so it needs no handling there. Old /md/<path>.md URLs are a 404 on a static export.

Verification (local)

  • pnpm run lint, pnpm exec tsc --noEmit, pnpm exec vitest run (390 tests): pass.
  • Server build, then next start with MDX unset:
    • /getting-started/introduction.md: 200 text/markdown; charset=utf-8, the page's markdown
    • /getting-started/nope.md, /nope.md, /md.md: 404
    • /md/getting-started/introduction.md: 308 to /getting-started/introduction.md, then 200 text/markdown
    • /getting-started/introduction: 200, its HTML carries /getting-started/introduction.md
    • POST /api/mcp tools/list: 200
  • Static export via next-build.sh: 20 .md files beside the pages, no out/md; same with DIST_DIR=build-distdir BASE_PATH=/sub (links read /sub/getting-started/introduction.md). The CLI's --format website with BASE_PATH=/sub: same. Served with serve: /getting-started/introduction.md 200 text/markdown, /md/... 404.
  • npx playwright test --update-snapshots against start.sh: 74 passed.

🤖 Generated with Claude Code

abernier and others added 3 commits October 2, 2026 01:05
…dpoint

An MCP client configured with the legacy SSE URL (/api/sse) retries in a loop,
and each cycle failed in production:

- `GET /.well-known/oauth-protected-resource/api/sse` fell through to the
  `[...slug]` catch-all, rendered at runtime where `MDX` is unset (it is only a
  build env on Vercel), and threw a 500. `/sitemap.xml` and any mistyped URL
  did the same.
- `GET /api/sse` made mcp-handler reach for Redis, which we do not run: it
  threw `redisUrl is required` as an unhandled rejection and never ended the
  response, so the function hung until Vercel timed it out.

`dynamicParams = false` on the catch-all makes every path that
`generateStaticParams` did not list a plain 404, which static export already
assumes. `disableSse: true` makes the SSE endpoints a plain 404; clients use
the streamable HTTP transport at /api/mcp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The markdown route had the same flaw as the page catch-all: a path that
`generateStaticParams` did not list was handled at runtime, where `MDX` is
unset on Vercel, and threw `MDX env var not set` (500). `dynamicParams = false`
next to `force-static` makes it a plain 404; the listed pages are still served
as `text/markdown`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The markdown of a page moves from `/md/<path>.md` to `/<path>.md`, e.g.
`/getting-started/introduction.md`. The route handler stays at
`src/app/md/[...slug]/route.ts`, since it cannot share a segment with
`[...slug]/page.tsx`, but `/md` is no longer a public URL:

- Server: an afterFiles rewrite maps `/:path+.md` to `/md/:path+.md`; it is
  checked before dynamic routes, so the catch-all never sees these paths. The
  old `/md/:path+.md` URLs permanently redirect to the new ones; redirects only
  apply to the incoming request, so this does not loop with the rewrite.
- Static export: no rewrites there, so `next-build.sh` and the CLI's website
  build move the exported `md/` files beside the pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@abernier
abernier changed the base branch from claude/error-origin-357e5d to main October 1, 2026 23:34
# Conflicts:
#	.changeset/unknown-path-404.md
@abernier
abernier merged commit 3167b88 into main Oct 1, 2026
3 checks passed
@pmndrs-release pmndrs-release Bot mentioned this pull request Oct 1, 2026

This branch was successfully deployed

1 active deployment
Preview — 8c3bbd8f Deployed Oct 1, 2026 by abernier via main-job #1039
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant