More of a site’s readers are programs now: coding agents fetching your docs, assistants summarising a post, scripts checking a status page. They all get the HTML, then guess at where the content is in it. HTTP has had a better answer since the start: the client says what it can read in the Accept header, and the server picks a representation. vela enable content-negotiation sets that up so a page can answer text/markdown with clean Markdown and application/json with its data, from the same load that renders the HTML.
This page is served that way. Add .md to its URL, or fetch it with Accept: text/markdown, and you get the Markdown this tutorial was written in.
Prerequisites
Any SvelteKit project. No backend needed, and it does not have to be one vela created. The output below is from a vela project. In a plain npx sv create project the example page lands at src/routes/negotiate because there is no (public) group, <Negotiate /> goes into src/routes/+layout.svelte, and src/hooks.server.ts is created if the project has none.
Run the command
✔ Installed sveltekit-negotiate ✔ Created 4 files, modified 3 src/lib/negotiate.ts src/routes/(public)/negotiate/+page.server.ts src/routes/(public)/negotiate/+page.svelte src/hooks.server.ts src/hooks.ts src/routes/(public)/root-layout.svelte
What was generated
The formats
One module declares which types the site can serve and the URL extension that stands in for each header:
import { createNegotiation } from "sveltekit-negotiate";
export const { handle, reroute, negotiate, Negotiate } = createNegotiation({
"text/markdown": { extension: ".md" },
"application/json": { extension: ".json" },
});
The hooks
The server hook reads the Accept header or the extension, lets the page render, and swaps the HTML response for the negotiated payload on the way out. It runs first in the sequence:
import { env } from "$env/dynamic/private";
import { sequence } from "@sveltejs/kit/hooks";
import { handlePocketbase } from "@velastack/pocketbase";
import { handle as handleNegotiate } from "$lib/negotiate";
export const handle = sequence(
handleNegotiate,
handlePocketbase({
pocketbaseUrl: env.POCKETBASE_URL,
superuserEmail: env.POCKETBASE_SUPERUSER_EMAIL,
superuserPassword: env.POCKETBASE_SUPERUSER_PASSWORD,
}),
);
The universal reroute makes /about.md hit the /about route:
import { reroute as negotiateReroute } from "$lib/negotiate";
export const reroute = ({ url }) => negotiateReroute(url.pathname);
A <Negotiate /> component is added to the public layout. It carries the payload out of the page render so the hook can pick it up.
An example page
/negotiate shows the whole idea in a dozen lines. The load returns its data for the HTML as usual, and spreads in a Markdown version. The handler only runs when Markdown was asked for:
import { negotiate } from "$lib/negotiate";
export const load = async ({ locals }) => {
const message = "Hello, content negotiation!";
return {
message,
...negotiate(locals, {
"text/markdown": () => `# ${message}`,
}),
};
};
Try it
curl -H "Accept: text/markdown" http://localhost:5173/negotiate
# Hello, content negotiation!
curl http://localhost:5173/negotiate.md
# Hello, content negotiation!A browser still gets the page. A page that has no handler for the requested type answers 406 Not Acceptable rather than HTML, so a client never mistakes one for the other. Every HTML response on a negotiable URL also carries Vary: accept, so a shared cache does not hand HTML to the next client asking for Markdown.
Make a real page negotiable
The blog is the obvious candidate: posts are Markdown already. Its content module exposes the raw source of a post, so a server load next to the post route can hand it over:
import { negotiate } from "$lib/negotiate";
import { getBlogPost, getBlogPostRaw } from "$lib/content";
export const load = async ({ params, locals }) => {
const post = getBlogPost(params.slug);
return {
...negotiate(locals, {
"text/markdown": () => getBlogPostRaw(params.slug) ?? "",
"application/json": () =>
post && { slug: post.slug, title: post.title, tags: post.tags },
}),
};
};Now /blog/hello-world.md is the post as written, and /blog/hello-world.json is its metadata. The HTML page is unchanged.
Going further
- Composes with i18n: the two reroute hooks chain, so
/es/blog/hola.mdresolves - For write access from scripts, API keys are the other half
- Undo with
vela disable content-negotiation - Pattern page: Content Negotiation · Docs: vela enable content-negotiation · sveltekit-negotiate on GitHub


