Markdown endpoints

Let agents retrieve a clean Markdown representation of each documentation page.

Included in starter templates

This functionality is already configured in the starter templates. Follow the complete setup in the Quickstart.

Every documentation page has a public Markdown mirror. The page at /guides/installation is available as /guides/installation.md; the home page is available as /index.md. These endpoints return the generated, frontmatter-free Markdown source with:

text
content-type: text/markdown; charset=utf-8

This is the preferred retrieval surface for AI agents. It preserves headings, lists, links, and code examples while avoiding navigation, search controls, and other HTML that would be irrelevant to an answer.

Public URLs, internal routing

The *.md URL is part of your public contract. Each framework routes it to an internal handler differently:

  • React Router redirects the public URL through middleware to a resource route.
  • Next.js rewrites it through proxy.ts to a Route Handler.
  • Astro generates a static [...,slug].md endpoint.

The implementation details stay internal; agents always request the same stable public URL. A request for an unknown page returns 404 Not Found rather than falling through to the documentation shell.

Keep the source of truth singular

Use markdownForPage() with the generated page registry rather than maintaining separate AI-facing files. That ensures the UI, search index, llms.txt, llms-full.txt, and Markdown mirrors all represent the same release of the documentation.

The Markdown is public content. Do not put secrets, private runbooks, or instructions that should not be exposed to browsers into the configured documentation directory.

React Router

Create a project

The React Router template generated by create-heyo-docs already includes the public Markdown redirect, its internal resource route, and the route registration. An agent can request /getting-started.md immediately after the project is deployed.

bun create @heyo-sh/heyo-docs my-docs --template react-router

Manual, minimal configuration

Add an internal resource route before the documentation catch-all. It resolves the requested Markdown pathname against the server-side virtual registry:

app/routes.ts
route("__heyo-docs/markdown/*", "routes/markdown.ts"),
route("*", "routes/docs.tsx"),
app/routes/markdown.ts
import { markdownForPage, pathnameFromMarkdownPath } from "@heyo-sh/heyo-docs";
import type { LoaderFunctionArgs } from "react-router";

import { pages } from "virtual:heyo-docs-content/server";

export function loader({ params }: LoaderFunctionArgs) {
  const pathname = pathnameFromMarkdownPath(`/${params["*"] ?? ""}`);
  const page = pathname
    ? pages.find((candidate) => candidate.slug === pathname)
    : undefined;

  if (!page)
    return new Response("Not Found", {
      status: 404,
      headers: { "content-type": "text/plain; charset=utf-8" },
    });

  return new Response(markdownForPage(page), {
    headers: { "content-type": "text/markdown; charset=utf-8" },
  });
}

Finally, redirect valid public *.md paths to the internal resource route from app/root.tsx:

ts
import { pathnameFromMarkdownPath } from "@heyo-sh/heyo-docs";
import type { Route } from "./+types/root";

const markdownResourcePrefix = "/__heyo-docs/markdown";

const markdownMiddleware: Route.MiddlewareFunction = async (
  { request },
  next,
) => {
  const url = new URL(request.url);
  if (
    !url.pathname.startsWith(`${markdownResourcePrefix}/`) &&
    pathnameFromMarkdownPath(url.pathname) !== undefined
  ) {
    return Response.redirect(
      new URL(`${markdownResourcePrefix}${url.pathname}${url.search}`, url),
      307,
    );
  }
  return next();
};

export const middleware = [markdownMiddleware];

Next.js

Create a project

The Next.js template generated by create-heyo-docs already includes proxy.ts and its internal Markdown Route Handler. The proxy keeps *.md URLs outside the App Router documentation catch-all.

bun create @heyo-sh/heyo-docs my-docs --template next

Manual, minimal configuration

Generate the content registry before development and builds, then re-export markdownPages from your server-only app/lib/docs.ts helper. Add the proxy:

proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  if (pathname.endsWith(".md"))
    return NextResponse.rewrite(
      new URL(`/heyo-docs-internal/markdown${pathname}`, request.url),
    );
  return NextResponse.next();
}

Then add the internal Route Handler. It returns a direct Markdown response or a 404, never the rendered documentation shell:

app/heyo-docs-internal/markdown/[[...slug]]/route.ts
import {
  markdownForPage,
  pathnameFromMarkdownPath,
} from "@heyo-sh/heyo-docs/node";

import { markdownPages } from "../../../lib/docs";

export async function GET(
  _request: Request,
  { params }: { params: Promise<{ slug?: string[] }> },
) {
  const { slug } = await params;
  const pathname = pathnameFromMarkdownPath(`/${slug?.join("/") ?? ""}`);
  const page = pathname
    ? markdownPages.find((candidate) => candidate.slug === pathname)
    : undefined;

  if (!page) return new Response("Not Found", { status: 404 });

  return new Response(markdownForPage(page), {
    headers: { "content-type": "text/markdown; charset=utf-8" },
  });
}

Astro

Create a project

The Astro template generated by create-heyo-docs includes a static dynamic route for every Markdown mirror. Astro emits these files during astro build, without a runtime rewrite or middleware.

bun create @heyo-sh/heyo-docs my-docs --template astro

Manual, minimal configuration

After adding heyoDocsAstro({ config }) to astro.config.ts, add a dynamic API route that enumerates the generated page registry:

src/pages/[...slug].md.ts
import type { APIRoute } from "astro";
import { markdownForPage, pathnameFromMarkdownPath } from "@heyo-sh/heyo-docs";

import { pages } from "virtual:heyo-docs-content/server";

export function getStaticPaths() {
  return pages.map((page) => ({
    params: { slug: page.slug === "/" ? "index" : page.slug.slice(1) },
  }));
}

export const GET: APIRoute = ({ params }) => {
  const pathname = pathnameFromMarkdownPath(`/${params.slug ?? ""}.md`);
  const page = pathname
    ? pages.find((candidate) => candidate.slug === pathname)
    : undefined;

  if (!page)
    return new Response("Not Found", {
      status: 404,
      headers: { "content-type": "text/plain; charset=utf-8" },
    });

  return new Response(markdownForPage(page), {
    headers: { "content-type": "text/markdown; charset=utf-8" },
  });
};