Bosia Bosia v1.2.9

Server Metadata

Set page titles, meta tags, Open Graph, and lang attributes with the metadata() function.

The metadata() function lets you define SEO and head tags for each page, running on the server before load().

Basic Usage

Export a metadata function from +page.server.ts:

import type { MetadataEvent } from "bosia";

export function metadata({ params }: MetadataEvent) {
	return {
		title: "About — My App",
		description: "Learn more about our app.",
	};
}

This renders a <title> tag and a <meta name="description"> tag in the page <head>.

Open Graph & Social Tags

Use the meta array to add Open Graph, Twitter Card, or any custom meta tags:

export function metadata({ params }: MetadataEvent) {
	return {
		title: "Blog Post",
		description: "A great blog post.",
		meta: [
			{ property: "og:title", content: "Blog Post" },
			{ property: "og:description", content: "A great blog post." },
			{ property: "og:type", content: "article" },
			{ name: "twitter:card", content: "summary_large_image" },
		],
	};
}

Tags with property render as <meta property="...">, tags with name render as <meta name="...">.

Set the <html lang> attribute and add <link> tags for canonical URLs, hreflang alternates, and more:

export function metadata() {
	return {
		title: "Mon Blog",
		lang: "fr",
		link: [
			{ rel: "canonical", href: "https://example.com/blog" },
			{ rel: "alternate", href: "https://example.com/en/blog", hreflang: "en" },
			{ rel: "alternate", href: "https://example.com/fr/blog", hreflang: "fr" },
		],
	};
}

Passing Data to load()

The data property lets you share fetched data with load(), avoiding duplicate queries:

import type { MetadataEvent, LoadEvent } from "bosia";

export function metadata({ params }: MetadataEvent) {
	const post = await db.getPost(params.slug);
	return {
		title: `${post.title} — Blog`,
		description: post.excerpt,
		meta: [{ property: "og:title", content: post.title }],
		// Pass to load() — avoids a second DB query
		data: { post },
	};
}

export async function load({ params, metadata }: LoadEvent) {
	// Reuse data from metadata(), fall back to fresh query
	const post = metadata?.post ?? (await db.getPost(params.slug));
	return { post };
}

The data object from metadata() becomes event.metadata in load(). If no metadata() function exists, event.metadata is null.

MetadataEvent Properties

Property Type Description
params Record<string, string> Dynamic route parameters
url URL The request URL
locals Record<string, any> Data set by middleware hooks
cookies Cookies Read/write cookies
fetch Function Fetch helper (cookies forwarded same-origin only — see Server Loaders → Cookie Forwarding)

Metadata Return Type

Property Type Description
title string Page <title> tag
description string <meta name="description"> tag
meta Array<{ name?: string; property?: string; content: string }> Custom meta tags
lang string <html lang> attribute
link Array<{ rel: string; href: string; hreflang?: string }> <link> tags (canonical, hreflang, etc.)
data Record<string, any> Data passed to load() as event.metadata

All properties are optional.

Form Actions

metadata() also runs when a <form method="POST"> submit re-renders the page, so titles, meta tags, lang and the data handed to load() are the same as on a plain GET.

Redirects & Errors

You can throw redirect() or error() from inside metadata() — they behave exactly as they do in load():

import { error } from "bosia";
import type { MetadataEvent } from "bosia";

export async function metadata({ params }: MetadataEvent) {
	const post = await db.getPost(params.slug);
	if (!post) error(404, "Post not found");
	return { title: post.title };
}

Sign-in checks belong in a hook guard, which runs before metadata(). A +layout.server.ts can't stop metadata(): the two start at the same time.

Any other error inside metadata() is logged and the page renders without metadata, rather than failing the request.

Title vs ``

Never set <title> in both metadata() and a <svelte:head> on the same route — they do not merge, and the winner differs by audience.

metadata()'s title is what ships in the SSR HTML, so scrapers, curl and non-JS crawlers read it. Svelte compiles <svelte:head><title> to a document.title write that runs on mount and on every reactive update, so the browser tab ends up showing that one instead. A route declaring both advertises one title and displays another.

Pick metadata() for anything share-critical and delete the competing <svelte:head><title>.

Client-Side Navigation

During client-side navigation, Bosia sends title, description, meta, link and lang from metadata() in the data response. The client router rebuilds the <head> from them without a full page reload, so og:*, twitter:*, canonical and robots match what a hard load of the same URL would render.

metadata.data is deliberately not sent — it feeds load() on the server and may hold values the browser should never see.

The router only replaces the tags metadata() produced (marked data-bosia-meta in the HTML). Tags you add through <svelte:head> or a plugin's head fragment are left alone.

<svelte:head> is server-rendered into the raw <head>, at the end, after your app.html markup. Scripts and stylesheets there load like any in <head>: a plain <script src> blocks the first paint until it downloads, so add async or defer to third-party scripts. Inline scripts there also need the CSP nonce when a policy is on, and data inside {@html} scripts such as JSON-LD must escape < (see Security). Keep it to head tags (title, meta, link, style, script, noscript): a <div>, <img> or bare text there ends <head> early in the browser and breaks hydration.

On client navigation, a page with no title of its own gets the title a hard load shows: the one its layouts write, else the <title> in app.html, else Bosia App. Svelte sets a title only when a component mounts or the title changes, so a layout that stays mounted never sets its own again. Bosia remembers what each layout wrote, including a title from an expression ({data.site}) or a child component like <Seo>, and puts it back. Staying on the same page (an invalidate(), a form action, ?page=2) keeps a title a child component set. Some cases are not caught. A component that writes the title after a layout's {@render children()} is taken for the page's, so it is replaced when you move to another page; put it before {@render children()}. A layout that sets document.title in $effect or onMount is not remembered; use <svelte:head><title>. A layout title that changes between navigations (({unread}) Inbox) comes back with the value it had at the last navigation, until it changes again. A layout that renders {@render children()} later than its title (inside {#if}, {#await}, or after onMount) is not remembered. A page that writes the title in $effect.pre or a legacy $: can be taken for its layout's; use <svelte:head><title> in the page. Between two pages of one route (/blog/a to /blog/b), a title the page shows only inside {#if} stays when the next page hides it; use metadata().title for per-item titles. A <title> from a plugin's head fragment is replaced by the default too; use metadata().title or the <title> in app.html instead.

When nothing sets a title (not metadata(), <svelte:head>, a plugin or app.html), the server adds <title>Bosia App</title> as the last tag in <head>. Error pages use 404 — Not Found (status and message) the same way, so a <svelte:head><title> in +error.svelte still wins. Client navigation to an error page sets the same title. A <title> in app.html turns the Bosia App fallback off; on error pages the status title still goes ahead of an app.html title placed after %bosia.head%.

Timeouts

The metadata() function has a configurable timeout via the METADATA_TIMEOUT environment variable (in milliseconds). If metadata() takes too long, it times out gracefully and the page renders without metadata.

Caching Interaction

If metadata() calls cookies.get() or cookies.getAll(), the data response is automatically marked with Cache-Control: private, no-cache. See Server Loaders for details.