Astro Markdown Endpoints for LLMs

Nov 12, 2025
· 3 min read

Large language models love good Markdown. It’s concise, structured, and easy to feed into prompt contexts with minimal effort.

In this post, I’ll share how I set up dynamic Markdown endpoints for the documentation portal I’m building for my project at Levelogy. Using Astro’s API routes, each article in the collection can be fetched as clean, lightweight Markdown.

This post assumes you’re familiar with Astro’s content collections. If you’re not, check out the Astro documentation for more information.

Endpoint overview

Astro treats any .ts/.js file in src/pages as a route. In my setup, the file src/pages/docs/[collection]/[article].md.ts generates Markdown for each documentation entry by mapping the collection and article parameters from the URL to the corresponding content file.

import type { APIRoute } from 'astro';
import { getCollection, type CollectionEntry } from 'astro:content';
type DocumentationCollection = CollectionEntry<'documentation'>;
export const GET: APIRoute = async ({ props }) => {
const { article } = props as { article: DocumentationCollection };
// Map the collection and article parameters to the corresponding content file.
// ...
const rawContent = await fs.readFile(filePath, 'utf-8');
const filteredContent = stripFrontmatter(rawContent);
// Read the content file and strip the frontmatter and then return the Markdown.
// ...
return new Response(filteredContent, {
status: 200,
headers: { "Content-Type": "text/markdown; charset=utf-8" },
});
};

Making Astro know what to build

getStaticPaths tells Astro exactly which [collection]/[article] combinations to pre-render whenever you use dynamic route segments.

During the build, Astro runs this function, expects an array of { params: { … } } objects, and emits one static file for each entry.

Without getStaticPaths, Astro wouldn’t know which docs exist, so it couldn’t generate the Markdown files. Levelogy is entirely statically generated, so this is essential.

Building the Markdown

Each documentation article is stored as an MDX file with frontmatter that defines its metadata, things like the collection it belongs to, its slug, version tags and more. This is information that’s not needed in the Markdown output, so we need to strip it out.

When the endpoint loads an article, it uses that info to find the right file and read its contents. A small helper then steps in to clean things up: it uses gray-matter to remove the frontmatter and leave just the Markdown body which is all we need.

import matter from 'gray-matter';
function stripFrontmatter(content: string): string {
const { content: body } = matter(content);
return body;
}

Once the Markdown content is ready, the endpoint sends it back with a standard 200 status and a key detail: the Content-Type header is set to text/markdown; charset=utf-8. This tells the client that the response is Markdown content, which is important for LLMs and even browsers to understand.

Let’s get fancy

Once you’ve got the basics working, there’s plenty of room to play. For Levelogy.com, I wanted to take it a step further and add a “Copy page” button powered by Alpine.js.

The button lets anyone browsing the docs grab the full Markdown output with one click.

<div
x-data=`{
copied: false,
async copyMarkdown() {
try {
const response = await fetch('${markdownUrl}');
if (!response.ok) throw new Error('Failed to fetch markdown');
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);
this.copied = true;
setTimeout(() => { this.copied = false; }, 2000);
} catch (error) {
console.error('Failed to copy markdown:', error);
}
}
}`
>
<Button
variant="outline"
size="default"
iconLeft="lucide:copy"
aria-label="Copy page"
x-on:click="copyMarkdown()"
>
<span x-text="copied ? 'Copied' : 'Copy page'">Copy page</span>
</Button>
</div>

This little Alpine.js block handles the “Copy page” magic. When someone clicks the button, it fetches the Markdown from the endpoint, copies it to their clipboard, and briefly swaps the label to “Copied” for feedback.

It’s not perfect, but get’s the job done.