npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 extension

Features

  • Content negotiation driven by the Accept header (with full q-value support)
  • URL extension fallback (/posts/hello.md, /posts/hello.json, …)
  • Works with SvelteKit's regular load functions — no bespoke endpoints
  • Pluggable per-type serializers (JSON, Markdown, XML, YAML, anything)
  • Tiny, zero runtime dependencies, fully typed
  • Sets the Vary: accept header so caches behave correctly

Install

npm install sveltekit-negotiate
# or
pnpm add sveltekit-negotiate
# or
yarn add sveltekit-negotiate

Requires 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

  1. On each request, handle parses the Accept header (and the URL extension), compares it to the registered types, and picks the best match. If text/html is tied or preferred it does nothing and SvelteKit renders normally.
  2. When a non-HTML type is chosen, handle stashes it on event.locals.negotiate and lets the page render as usual.
  3. Any load that called negotiate(locals, { … }) produces a serialized payload which the <Negotiate /> component embeds in <svelte:head> as <script type="text/plain" id="__negotiate">…</script>.
  4. On the way back out, handle extracts that payload, returns it with the negotiated Content-Type, and adds Vary: accept so CDNs cache correctly.
  5. reroute rewrites /foo.md/foo so 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 -- --open

Everything 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 mode

Type-check with npm run check and format with npm run format.

Building & publishing

Build a publishable package:

npm run build

Pack it locally to inspect the tarball:

npm pack

Publish to npm:

npm publish

License

MIT