SvelteKit load Functions for Meta and Canonical

SvelteKit’s load functions are where a route’s data β€” and therefore its metadata β€” should originate. Feed a load result into a svelte:head block and every route emits its own title, description, and canonical in the server-rendered HTML, then keeps them correct as the user navigates client-side. This article deepens the SvelteKit SEO and routing guide by focusing on the load-to-head pipeline and canonical normalization.

Problem Statement

SvelteKit offers two flavors of load, and choosing correctly matters for both correctness and where the code runs. A server load (in +page.server.ts) executes only on the server; it can read request headers, touch a database, and use secrets, and its returned data is serialized into the server-rendered HTML. A universal load (in +page.ts) runs on the server for the first request and then on the client for subsequent navigations. Either can feed metadata, because the first paint is always server-rendered β€” but the canonical URL is where teams slip, computing it on the client or emitting the raw request URL complete with tracking parameters.

The dependable pattern is to derive the canonical from the request URL inside load, strip the parameters that create duplicates, return it as data, and render it through svelte:head. Because svelte:head is written into the HTML during server rendering and also updates reactively on navigation, the metadata is right in the first response and stays right afterward.

SvelteKit load feeding svelte:head on server and client A server or universal load function normalizes the canonical and returns metadata; the page passes it to svelte:head, which is written into the server HTML and also updates reactively on client navigation. load() β†’ data β†’ svelte:head, in both render paths +page.server.ts server-only load +page.ts universal load normalize canonical strip utm, return data svelte:head title, meta, canonical server HTML on first load + reactive update on client navigation
Either load flavor normalizes the canonical and returns metadata; svelte:head writes it into the server HTML and re-renders it on client navigation.

Step-by-step fix

  1. Return metadata from load, canonical included. Build the canonical from url, stripping parameters that create duplicates, and return it alongside content.

    // src/routes/articles/[slug]/+page.ts
    import type { PageLoad } from './$types';
    
    export const load: PageLoad = async ({ params, url, fetch }) => {
      const res = await fetch(`/api/articles/${params.slug}`);
      const article = await res.json();
      const canonical = `${url.origin}/articles/${params.slug}`;
      return { article, canonical };
    };
  2. Use a server load when the data is server-only. For database access or request headers, move the function to +page.server.ts; the returned data still lands in the server HTML.

    // src/routes/articles/[slug]/+page.server.ts
    import type { PageServerLoad } from './$types';
    import { error } from '@sveltejs/kit';
    
    export const load: PageServerLoad = async ({ params, url }) => {
      const article = await db.article.find(params.slug);
      if (!article) throw error(404, 'Not found');
      return { article, canonical: `${url.origin}/articles/${params.slug}` };
    };
  3. Render the head from that data in svelte:head. The block is server-rendered into the HTML and updates reactively on navigation.

    <!-- src/routes/articles/[slug]/+page.svelte -->
    <script lang="ts">
      export let data;
    </script>
    
    <svelte:head>
      <title>{data.article.title} | Example</title>
      <meta name="description" content={data.article.summary} />
      <meta property="og:title" content={data.article.title} />
      <link rel="canonical" href={data.canonical} />
    </svelte:head>
    
    <article>
      <h1>{data.article.title}</h1>
    </article>
  4. Normalize tracking and pagination parameters. Strip them before building the canonical so variant URLs consolidate to one.

    const clean = new URL(url);
    ['utm_source', 'utm_medium', 'utm_campaign', 'ref'].forEach((p) => clean.searchParams.delete(p));
    const canonical = `${clean.origin}${clean.pathname}`;
  5. Emit JSON-LD from the same data. Render a structured-data script in the component so it ships in the server HTML.

    <svelte:head>
      {@html `<script type="application/ld+json">${JSON.stringify({
        '@context': 'https://schema.org', '@type': 'Article', headline: data.article.title
      })}</script>`}
    </svelte:head>
Canonical normalization inside load The raw request URL carrying tracking parameters is stripped to a single clean canonical before load returns it. One clean canonical, built before load returns /articles/first?utm_source=news&utm_medium=email&ref=tw raw request URL β€” returned verbatim, every variant claims canonical strip utm_*, ref https://example.com/articles/first one canonical for every variant of the page
Deriving the canonical in load and stripping tracking parameters collapses every variant of the URL to a single self-referencing canonical.

Validation

Confirm the head is in the server response and that navigation keeps the canonical aligned with the current route.

Four checks that prove the head is server-rendered A checklist confirming the route title is in the server HTML, the canonical drops tracking parameters, the head survives with JavaScript disabled, and the canonical tracks client navigation, each paired with the method that verifies it. Four checks that the head ships server-rendered βœ“ Server HTML has the route title curl | grep <title> βœ“ Canonical drops tracking params request ?utm_source=x βœ“ Head survives with JavaScript off disable JS, view source βœ“ Canonical tracks client navigation assert after transition
Each validation check pairs with the exact method that proves it, confirming the head is in the server response and stays aligned as the client navigates.
  • Server HTML has the route title. curl -sL https://example.com/articles/first/ | grep -i "<title>" returns the article-specific title.
  • Canonical is normalized. Request the route with a ?utm_source=x suffix and confirm the response’s canonical omits the parameter.
  • JavaScript disabled still shows the head. Disable JavaScript and view source; title, description, and canonical remain because svelte:head was server-rendered.
  • Client navigation updates the canonical. After a client-side transition, read the canonical from the DOM; it should match the new URL.
// After client navigation, assert the canonical follows the route
console.assert(
  document.querySelector('link[rel="canonical"]').href === location.origin + location.pathname,
  'Canonical did not track the client navigation'
);

Common Pitfalls

  • Computing the canonical in onMount. A client-only canonical is missing from the server HTML the crawler indexes first; build it in load.
  • Emitting the raw request URL. Returning the URL with tracking parameters as canonical lets each variant claim canonical status, splitting signals.
  • Choosing the wrong load flavor. Putting server-only work in a universal load leaks it to the client or fails on navigation; use +page.server.ts for secrets and database access.
  • Setting the title with document.title. Imperative assignment in a client effect bypasses SSR; always use svelte:head.
  • Forgetting a per-route description. Relying on a layout-level default gives many routes the same description; return one from each route’s load.
Server load versus universal load A capability matrix contrasting where server load and universal load run and what each can access, so the right flavor is chosen per route. Pick the load flavor by what the route needs Capability +page.server.ts +page.ts Runs during server render βœ“ βœ“ Runs on client navigation βœ— βœ“ Database access & secrets βœ“ βœ— Reads request headers βœ“ βœ— Feeds svelte:head metadata βœ“ βœ“
Both flavors feed metadata into the server HTML; choose server load only when the route needs server-only resources like secrets, a database, or request headers.

Frequently Asked Questions

What is the difference between server and universal load in SvelteKit for SEO?

A server load runs only on the server, so it suits secrets, database access, and reading request headers, and its data is embedded in the server-rendered HTML. A universal load runs on the server for the first request and then on the client during navigation. For SEO metadata, both work because the first response is server-rendered; choose server load when the data needs server-only resources and universal load when it can run in either place.

How do I set a canonical URL in SvelteKit load?

Build the canonical from the request URL in a load function, stripping tracking and pagination parameters, and return it as load data. The page component then reads that value and renders a canonical link inside svelte:head. Computing it in load keeps the canonical correct in the initial server HTML and consistent when the client navigates between routes.

Does svelte:head metadata work without JavaScript?

Yes. During server rendering SvelteKit writes the svelte:head contents into the document head of the HTML response, so a crawler that runs no JavaScript still sees the title, description, and canonical. The same svelte:head block also updates reactively during client-side navigation, so metadata stays correct in both the initial load and subsequent transitions.

← Back to SvelteKit SEO and Routing Guide