[ 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.
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.
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.
Merge without dropping foreign links
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:
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.
Follow next links as a client
Walk a paginated API until rel="next" runs out. The server can then change strategies or
rename a parameter without breaking the client.
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
Paginate lists — offset and keyset pages, and the
Linkvaluespaginatewrites.Publish RSS, Atom and JSON feeds —
rel="hub"andrel="self"for WebSub.Join the IndieWeb — discover a Webmention endpoint from the header first.
@sdxc/link-header— theLinktype and the empty-header cases.