Skip to main content

Link Previews in SvelteKit: One Place for Share Tags, Page Data That Feeds It, and the Prerender Gotcha

Updated 8 min readMarkdown
Link Previews in SvelteKit: One Place for Share Tags, Page Data That Feeds It, and the Prerender Gotcha

Most of my client work is in Next.js, where the framework hands you a metadata object and quietly turns it into tags. When I started building a couple of things in SvelteKit, I expected the same. What I got instead was <svelte:head>: you write the tags yourself, as markup, wherever you like. It's simple and flexible, and that flexibility is exactly how link previews go wrong.

This post is the setup I've settled on for SvelteKit 2 with Svelte 5: one place that renders the share tags, page data that feeds it, and the few configuration details that decide whether a crawler ever sees them.

How <svelte:head> behaves

Any component can render <svelte:head>, and everything inside it ends up in the document head. During server-side rendering, SvelteKit collects those blocks and writes them into the HTML it sends. That's the part link preview crawlers care about, since most of them read the raw HTML and don't run your JavaScript.

The important detail: SvelteKit doesn't merge or de-duplicate head tags. If your root layout renders a default og:title and your blog post page renders its own, both tags end up in the HTML. Which one a given app picks is up to that app. In my experience the first tag usually wins, which tends to be the layout's default, so every post shares with your site name instead of its title.

So the common pattern of "defaults in the layout, overrides in the page" doesn't work the way it does in frameworks that merge metadata objects. You need a different shape.

One renderer, fed by page data

Instead of letting every page write tags, I render all share tags in exactly one component, and pages only provide data.

Each page's load returns a meta object:

// src/routes/blog/[slug]/+page.server.ts
import { error } from "@sveltejs/kit";
import { getPost } from "$lib/server/posts";
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async ({ params }) => {
  const post = await getPost(params.slug);
  if (!post) error(404, "Post not found");

  return {
    post,
    meta: {
      title: post.title,
      description: post.excerpt,
      image: `/og/blog/${post.slug}.png`,
      imageAlt: `Cover image for "${post.title}"`,
      type: "article",
    },
  };
};

Then a small type and a component that turns it into tags:

// src/lib/meta.ts
export type PageMeta = {
  title: string;
  description: string;
  image?: string;     // path or absolute URL
  imageAlt?: string;
  type?: "website" | "article";
  noindex?: boolean;
};

export const SITE_URL = "https://www.example.com";
export const SITE_NAME = "Example Studio";

export const DEFAULT_META: PageMeta = {
  title: SITE_NAME,
  description: "Small web projects, built carefully.",
  image: "/og/default.png",
  imageAlt: `${SITE_NAME} logo`,
  type: "website",
};

export function absolute(pathOrUrl: string): string {
  return new URL(pathOrUrl, SITE_URL).href;
}
<!-- src/lib/components/ShareTags.svelte -->
<script lang="ts">
  import { page } from "$app/state";
  import { DEFAULT_META, SITE_NAME, absolute, type PageMeta } from "$lib/meta";

  let meta: PageMeta = $derived({ ...DEFAULT_META, ...(page.data.meta ?? {}) });
  let url = $derived(absolute(page.url.pathname));
  let image = $derived(absolute(meta.image ?? DEFAULT_META.image!));
</script>

<svelte:head>
  <title>{meta.title === SITE_NAME ? SITE_NAME : `${meta.title} · ${SITE_NAME}`}</title>
  <meta name="description" content={meta.description} />
  <link rel="canonical" href={url} />
  {#if meta.noindex}<meta name="robots" content="noindex" />{/if}

  <meta property="og:type" content={meta.type} />
  <meta property="og:site_name" content={SITE_NAME} />
  <meta property="og:title" content={meta.title} />
  <meta property="og:description" content={meta.description} />
  <meta property="og:url" content={url} />
  <meta property="og:image" content={image} />
  <meta property="og:image:width" content="1200" />
  <meta property="og:image:height" content="630" />
  {#if meta.imageAlt}<meta property="og:image:alt" content={meta.imageAlt} />{/if}

  <meta name="twitter:card" content="summary_large_image" />
</svelte:head>

And it's rendered once, in the root layout:

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import ShareTags from "$lib/components/ShareTags.svelte";
  let { children } = $props();
</script>

<ShareTags />
{@render children()}

page.data is the merged data of every load in the current route, with the deepest one winning for the same key. So a page that returns meta overrides a layout that returns meta, and a page that returns nothing falls back to DEFAULT_META. You get the merge behaviour you wanted, but in your data instead of in the head.

The rule for the team is short: no og: or twitter: tags anywhere except ShareTags.svelte. A quick grep in CI enforces it:

# fails if any other file writes share tags
! grep -rlE 'property="og:|name="twitter:' src --include='*.svelte' | grep -v 'ShareTags.svelte'

($app/state needs SvelteKit 2.12 or newer. On older versions, $app/stores and $page.data do the same job.)

Why absolute URLs come from a constant

You'll notice I build og:url and og:image from a SITE_URL constant instead of page.url.origin. There are two reasons.

The first is proxies and preview deployments. Behind some hosting setups, the origin the app sees isn't the public one, and you end up with an internal host or http:// in your share tags.

The second one surprised me: prerendering. When SvelteKit prerenders a page at build time, there's no real request, so url.origin defaults to http://sveltekit-prerender. If your tags use the request origin, every prerendered page ships with that placeholder in og:image, and no crawler on earth can fetch it. You can set kit.prerender.origin in svelte.config.js to fix the origin, but a single constant avoids the question entirely.

The settings that decide whether crawlers see anything

All of the above assumes the server renders your page. Two page options can quietly turn that off:

  • export const ssr = false makes the page render only in the browser. The HTML the server sends is an empty shell, so the <svelte:head> content is never in it. If you set this in the root +layout.ts to get "SPA mode", every link on the site shares with whatever is in app.html.
  • adapter-static with a fallback page serves the same fallback HTML for every route that wasn't prerendered. Again, one set of tags for every URL.

For a static deployment, prerender the pages that people share:

// src/routes/blog/[slug]/+page.server.ts
export const prerender = true;

SvelteKit's crawler follows links from your entry pages to find which slugs to build. Posts that nothing links to won't be found that way, so list them with an entries export or in kit.prerender.entries.

If a section genuinely has to be client-only, like a logged-in dashboard, that's fine. Just accept that its links share with the default card, and make sure the default card is a good one.

Error pages and missing data

When load throws error(404), SvelteKit renders your +error.svelte. The layout still renders, so ShareTags still runs, but the page's meta was never returned. Without care, a deleted post shares with your homepage's title and image, looking perfectly normal.

I handle it in the component:

<script lang="ts">
  // inside ShareTags.svelte
  let meta: PageMeta = $derived(
    page.error
      ? { ...DEFAULT_META, title: `${page.status === 404 ? "Page not found" : "Something went wrong"}`, noindex: true }
      : { ...DEFAULT_META, ...(page.data.meta ?? {}) }
  );
</script>

Now a broken link looks broken in the card too, which is the honest outcome.

Checking what actually ships

npm run dev isn't a good test for any of this, because dev mode doesn't prerender, and the browser shows you the hydrated page. Build first, then look at the HTML the server sends:

npm run build && npm run preview
curl -s http://localhost:4173/blog/my-post | grep -oE '<meta (property|name)="(og|twitter):[^>]+>'

Two things to look for: each og: property appears exactly once, and every URL starts with your real domain. If you see sveltekit-prerender or localhost in there, the constant isn't being used somewhere.

Once it's deployed, I check a few live URLs from outside. The Twitter card checker on OG Scope, a free tool I built, fetches the page server-side without running JavaScript and shows the card tags it found, including whether twitter:card is present and which layout X would likely use. It's also a quick way to spot duplicate tags, because the metadata table lists what was actually in the HTML rather than what the component was supposed to render. The cards are estimates, not the platform's own renderer, but they're built from the same HTML the crawlers receive.

Summary

  • <svelte:head> doesn't merge. Render share tags in one component only.
  • Pages provide a meta object from load; the deepest one wins in page.data.
  • Build absolute URLs from a site constant, not the request origin, especially when prerendering.
  • ssr = false and SPA fallbacks mean crawlers get the shell. Prerender the pages people share.
  • Give error pages their own honest title and noindex.
  • Test the built output with curl, then the live URLs from outside.

SvelteKit makes it pleasant to write the head by hand. The trick is to write it in one place.


I'm Abid Ali, a freelance full stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I built OG Scope, a free link preview checker.

About the author

  • I'm Abid Ali, a full stack developer from Karachi with 3+ years of experience in TypeScript, Next.js, Gatsby, React and React Native. I build web apps, mobile apps and interactive UIs with solid API integration. Side projects: OG Scope (ogscope.pk), a free Open Graph checker; Digital Dialogue (digitaldialogue.pk), freelancing guides for Upwork and Fiverr beginners; and Meme Tap (memetap.pk), a free browser meme soundboard.

No comments yet. Be the first to comment.