sveltekit-negotiate
v0.2.0
Published
Drop-in HTTP content negotiation for SvelteKit. Serve the same route as HTML, Markdown, JSON, XML or any format driven by the Accept header or a URL extension.
Downloads
245
Maintainers
Readme
sveltekit-negotiate
Drop-in HTTP content negotiation
for SvelteKit. Serve the same route as HTML, Markdown, JSON, XML — or any other
format you choose — driven by the client's Accept header or a URL extension.
Write your data once in a load function and let your callers decide how they
want it.
curl -H 'Accept: text/html' https://example.com/posts/hello # rendered page
curl -H 'Accept: text/markdown' https://example.com/posts/hello # raw markdown
curl -H 'Accept: application/json' https://example.com/posts/hello # json payload
curl https://example.com/posts/hello.md # via extensionFeatures
- Content negotiation driven by the
Acceptheader (with full q-value support) - URL extension fallback (
/posts/hello.md,/posts/hello.json, …) - Works with SvelteKit's regular
loadfunctions — no bespoke endpoints - Pluggable per-type serializers (JSON, Markdown, XML, YAML, anything)
- Tiny, zero runtime dependencies, fully typed
- Sets the
Vary: acceptheader so caches behave correctly
Install
npm install sveltekit-negotiate
# or
pnpm add sveltekit-negotiate
# or
yarn add sveltekit-negotiateRequires Svelte 5 and SvelteKit 2.
Quick start
1. Configure the negotiated types
Create a single module where you declare the types you want to support. The returned helpers are bound to that configuration.
// src/lib/negotiate.ts
import { createNegotiation } from 'sveltekit-negotiate';
export const { handle, reroute, negotiate, Negotiate } = createNegotiation({
'text/markdown': { extension: '.md' },
'application/json': { extension: '.json' }
});2. Wire up the hooks
handle inspects the request, flags the chosen type on event.locals, and
rewrites the rendered HTML response to the negotiated payload on the way out.
reroute lets /posts/hello.md hit the same route as /posts/hello.
// src/hooks.server.ts
export { handle } from '$lib/negotiate';// src/hooks.ts
import type { Reroute } from '@sveltejs/kit';
import { reroute as negotiateReroute } from '$lib/negotiate';
export const reroute: Reroute = ({ url }) => negotiateReroute(url.pathname);3. Mount the <Negotiate /> component in your layout
The component writes the negotiated payload into <svelte:head> so handle
can pluck it back out server-side. Put it in your root layout once.
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { Negotiate } from '$lib/negotiate';
let { children } = $props();
</script>
<Negotiate />
{@render children?.()}4. Return per-type payloads from your load
Call negotiate() inside any load that wants to participate. Each handler
only runs when its type was actually selected, so rendering Markdown doesn't
cost you a JSON serialization.
// src/routes/posts/[slug]/+page.server.ts
import { negotiate } from '$lib/negotiate';
export const load = async ({ params, locals }) => {
const post = await getPost(params.slug);
return {
post,
...negotiate(locals, {
'text/markdown': () => post.body,
'application/json': () => ({ slug: post.slug, title: post.title, body: post.body })
})
};
};<!-- src/routes/posts/[slug]/+page.svelte -->
<script lang="ts">
let { data } = $props();
</script>
<h1>{data.post.title}</h1><article>{@html data.post.rendered}</article>That's it. The same URL now serves HTML, Markdown, or JSON depending on who's asking.
How it works
- On each request,
handleparses theAcceptheader (and the URL extension), compares it to the registered types, and picks the best match. Iftext/htmlis tied or preferred it does nothing and SvelteKit renders normally. - When a non-HTML type is chosen,
handlestashes it onevent.locals.negotiateand lets the page render as usual. - Any
loadthat callednegotiate(locals, { … })produces a serialized payload which the<Negotiate />component embeds in<svelte:head>as<script type="text/plain" id="__negotiate">…</script>. - On the way back out,
handleextracts that payload, returns it with the negotiatedContent-Type, and addsVary: acceptso CDNs cache correctly. rerouterewrites/foo.md→/fooso extension-based URLs hit the normal route tree.
If the negotiated type has no handler on a route, handle responds with a
406 Not Acceptable.
API
createNegotiation(types)
Creates the bound helpers for a set of MIME types.
createNegotiation({
'text/markdown': { extension: '.md' },
'application/json': { extension: '.json' },
'application/xml': {
extension: '.xml',
serialize: (value) => toXml(value)
}
});Each entry takes:
| Field | Type | Description |
| ----------- | ---------------------------- | ------------------------------------------------------------------------------------------------ |
| extension | string | URL suffix that maps to this MIME type (include the leading dot). |
| serialize | (value: unknown) => string | Optional. Defaults to JSON.stringify(value, null, 2), or the value itself if already a string. |
Returns { handle, reroute, negotiate, Negotiate }.
handle
A SvelteKit Handle
you re-export from src/hooks.server.ts. Compose it with other hooks via
sequence if
needed.
// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { handle as negotiate } from '$lib/negotiate';
import { handle as auth } from '$lib/auth';
export const handle = sequence(negotiate, auth);reroute(pathname)
A plain pathname transform: given a URL pathname, returns it with any
registered extension stripped (/posts/hello.md → /posts/hello), or
unchanged if no extension matches. Because it takes and returns a string,
it composes by function chaining with other pathname transforms — which
is how SvelteKit resolves its own reroute hook internally.
Wire it into SvelteKit's Reroute
hook in src/hooks.ts:
import type { Reroute } from '@sveltejs/kit';
import { reroute as negotiateReroute } from '$lib/negotiate';
export const reroute: Reroute = ({ url }) => negotiateReroute(url.pathname);To compose with other rerouters, just chain the calls:
import type { Reroute } from '@sveltejs/kit';
import { reroute as negotiateReroute } from '$lib/negotiate';
import { reroute as i18nReroute } from '$lib/i18n';
export const reroute: Reroute = ({ url }) => i18nReroute(negotiateReroute(url.pathname));negotiate(locals, handlers)
Call inside any load to produce the serialized payload for the negotiated
type. Returns an empty object when no type was negotiated or when the route
doesn't handle the selected type, so it's safe to spread unconditionally.
return {
post,
...negotiate(locals, {
'text/markdown': () => post.body,
'application/json': () => ({ title: post.title })
})
};Handlers are only invoked when their type was actually chosen.
<Negotiate />
Component that embeds the serialized payload into <svelte:head> where
handle can find it. Render it once in your root layout.
Recipes
Custom serializer
import YAML from 'yaml';
import { createNegotiation } from 'sveltekit-negotiate';
export const { handle, reroute, negotiate, Negotiate } = createNegotiation({
'application/yaml': {
extension: '.yaml',
serialize: (value) => YAML.stringify(value)
}
});Typed locals
If you want event.locals.negotiate to be strongly typed, extend App.Locals
in src/app.d.ts:
declare global {
namespace App {
interface Locals {
negotiate?: 'text/markdown' | 'application/json';
}
}
}
export {};Developing
Once you've installed dependencies with npm install, start the showcase app:
npm run dev
# or open the app in a new browser tab
npm run dev -- --openEverything inside src/lib is the library, src/routes is a preview app.
Run the test suite:
npm test # run once
npm run test:unit # watch modeType-check with npm run check and format with npm run format.
Building & publishing
Build a publishable package:
npm run buildPack it locally to inspect the tarball:
npm packPublish to npm:
npm publishLicense
MIT
