sdxc

Type to search, or start from one of these:

[ HTTP APIs ]

Parse and write Link headers

Parse and stringify RFC 8288 Link headers, keep foreign parameters through a merge, and drop malformed entries instead of failing the response.

Last updated 2026-10-13

A Link header is a list of URI-References with relation types and parameters. Splitting on commas is wrong: a target can contain a comma and a parameter value can be quoted. @sdxc/link-header scans angle brackets and quoted strings instead.

Malformed entries are dropped, so a merge into a response already under construction cannot fail on one bad link. @sdxc/pagination writes paging relations with this package, @sdxc/websub advertises hubs, and @sdxc/webmention discovers an endpoint from the header first.

npm add @sdxc/link-header

Link is RFC 8288. Newer fields such as RateLimit and Cache-Status use RFC 9651, which Canonical URLs, lazy routes and header fields covers with @sdxc/structured-fields.

Parse a header

parse splits the value into entries in source order. parse(null) is []. The call never throws. A type parameter lands in params.type, which is how a feed reader learns the advertised media type.

app/services/read-links.ts
import { parse } from "@sdxc/link-header";

let links = parse(
	'</a,b.css>; rel="preload"; as="style", </items?page=2>; rel="next"',
);

links[0]?.target; // "/a,b.css"
links[0]?.rels; // ["preload"]
links[0]?.params; // { as: "style" }

links.find((link) => link.rels.includes("next"))?.target; // "/items?page=2"

rels is the first rel parameter, lowercased and split on whitespace. params keeps the first of every other name, unquoted. An entry with no URI-Reference is skipped, so a malformed fragment ends that one link rather than the walk.

Relative targets stay relative. Resolve them with new URL(target, response.url) when the resource was fetched.

Write a header

serializeLink renders one link-value with a quoted rel and any extra parameters after it. stringify joins those texts. stringify([]) is null, so the caller deletes the header instead of writing an empty one.

app/services/write-links.ts
import { serializeLink, stringify } from "@sdxc/link-header";

stringify([
	serializeLink("https://api.example.com/items?page=2", "next"),
	serializeLink("/feed.xml", "self", { type: "application/atom+xml" }),
]);
// <https://api.example.com/items?page=2>; rel="next", </feed.xml>; rel="self"; type="application/atom+xml"

Values are quoted so a comma inside one cannot split the header.

raw is the exact source text, so parameters this caller did not read still round-trip. A paging helper that rewrites next keeps a preload that was already on the response:

app/services/set-next-link.ts
import { parse, serializeLink, stringify } from "@sdxc/link-header";

export function setNextLink(headers: Headers, nextUrl: string) {
	let kept = parse(headers.get("Link"))
		.filter((link) => !link.rels.includes("next"))
		.map((link) => link.raw);

	let value = stringify([...kept, serializeLink(nextUrl, "next")]);
	if (value === null) headers.delete("Link");
	else headers.set("Link", value);
}

paginate from @sdxc/pagination does this for the four paging relations. @sdxc/pagination still re-exports parse as parseLinkHeader; import from @sdxc/link-header when the header is the work, not the page.

Walk a paginated API until rel="next" runs out. The server can then change strategies or rename a parameter without breaking the client.

app/services/fetch-all.ts
import { parse } from "@sdxc/link-header";

export async function fetchAll<T>(start: string): Promise<T[]> {
	let items: T[] = [];
	let next: string | null = start;

	while (next !== null) {
		let response = await fetch(next);
		items.push(...((await response.json()) as T[]));

		let links = parse(response.headers.get("Link"));
		next = links.find((link) => link.rels.includes("next"))?.target ?? null;
	}

	return items;
}

Paginate lists is the server side of those headers. Write a typed API client does the same walk behind a schema on each page.

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.