Metadata & SEO
Titles, descriptions, Open Graph and Twitter cards, canonical URLs, sitemaps, robots.txt and structured data.
Pages and layouts describe their <head> with a metadata export. GioJS merges the root layout, the nested layouts and the page into one set of tags and renders them into the document head - on the server (streamed pages included) and again in the browser, so a <GioLink> navigation swaps the title and every tag for the next page's.
// app/layout.tsx
import type { Metadata } from '@gio.js/core';
export const metadata: Metadata = {
metadataBase: 'https://example.com',
title: { default: 'Acme', template: '%s | Acme' },
description: 'Rust-fast React apps.',
openGraph: { siteName: 'Acme', type: 'website' },
};
// app/pricing/page.tsx
export const metadata: Metadata = {
title: 'Pricing', // → <title>Pricing | Acme</title>
alternates: { canonical: '/pricing' }, // → https://example.com/pricing
};Titles
- A string sets the title. The nearest
templateset by a layout above the segment is applied (%sis replaced) - a layout's template never applies to its own title. { default }in a layout is the title for pages below it that set none.{ absolute: 'Home' }ignores every template.nullremoves an inherited title;{ template: null }stops templating below that layout.
generateMetadata
When the head depends on data, export generateMetadata instead (or as well - its result is merged over the static metadata of the same file). It receives the same context getServerSideProps gets - params, query, path, locale, ... - and, on pages, the props the page renders with as { props }, so nothing is fetched twice:
import type { Metadata, MetadataContext, MetadataExtras } from '@gio.js/core';
export const revalidate = 300;
export async function getServerSideProps(ctx: MetadataContext) {
const post = await db.posts.find(ctx.params.slug);
if (!post) return { notFound: true };
return { props: { post } };
}
export async function generateMetadata(ctx: MetadataContext, { props }: MetadataExtras): Promise<Metadata> {
const { post } = props as { post: Post }; // the gSSP result - no second query
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/posts/${ctx.params.slug}` },
openGraph: { type: 'article', images: [{ url: post.cover, width: 1200, height: 630 }] },
};
}- It runs after
getServerSideProps(its props are ready) and before the render, so the tags are part of the first streamed bytes. - Layouts'
generateMetadataget the context only (noprops); all segments resolve in parallel. - Reading credentials -
ctx.cookies,ctx.ip,ctx.host,ctx.scheme, the cookie/authorization header - makes the render personal exactly like it does ingetServerSideProps: a page exportingrevalidateis not cached, with a warning. Unlike ingetServerSideProps, this also holds forshell = 'cache'(PPR) pages: the head is part of the shell every visitor shares. Derive metadata from params and query. - The same goes for
{ props }on a PPR page. TheregetServerSidePropsmay read credentials, because its props stream after the shell - but a title built from those props would land in the shared shell. So whengetServerSidePropsread credentials andgenerateMetadatatakesprops, the shell is not cached (the page streams without it, with a warning). On such pages, build metadata fromctx.params/ctx.query- fetching by slug again if needed - and leavepropsalone. Pages whosegetServerSidePropsreads nothing personal keep usingpropsfreely. notFound()answers 404; a throw answers 500 like a failing render.
How segments merge
Root layout first, then each nested layout, then the page. The merge is shallow and the deepest segment wins per top-level field: a page that sets openGraph replaces the layout's whole openGraph object (share common values through a variable). undefined inherits, null removes the inherited value. Titles follow the template rules above.
og:title and og:description default to the page's resolved title (template applied) and description. A layout can therefore set a site-wide openGraph with only images, site name and type, and every page under it still shares with its own title and description. Leave twitter.title and twitter.description unset too: X reads the Open Graph tags when its own are missing.
Absolute URLs: metadataBase
Open Graph and Twitter images, openGraph.url, the canonical URL and alternate languages must be absolute for crawlers. Relative values resolve against the deepest metadataBase (a layout usually sets it), falling back to the GIO_SITE_URL environment variable. The base's path is kept: metadataBase: 'https://example.com/blog' turns /og.png into https://example.com/blog/og.png. Without either, the URL stays relative and the worker logs a warning (once per route). The request's Host header is never used: on a cached page, a client-chosen host would end up in every visitor's canonical URL.
Fields
| Field | Renders |
|---|---|
title | <title> |
description | <meta name="description"> |
keywords | <meta name="keywords"> (array joined with commas) |
authors | <meta name="author">, plus <link rel="author"> for a url |
robots | <meta name="robots"> from a string or { index, follow, noarchive, nosnippet, noimageindex, nocache, 'max-snippet', 'max-image-preview', 'max-video-preview' }; googleBot renders <meta name="googlebot"> |
alternates | { canonical, languages: { 'en-US': url } } → <link rel="canonical"> and <link rel="alternate" hreflang> |
openGraph | og:title and og:description (default: the page's title and description), og:url, og:site_name, og:locale, og:type, and per image og:image (+ :type, :width, :height, :alt) |
twitter | twitter:card, :site, :creator, :title, :description, :image (+ :alt) |
icons | A URL, a list, or { icon, apple, shortcut } → <link rel="icon" | "apple-touch-icon" | "shortcut icon"> with type/sizes/media |
manifest | <link rel="manifest"> |
themeColor | <meta name="theme-color">; a list of { color, media } for light/dark |
other | { name: content } → extra <meta name content> tags (verification codes, ...) |
Every value is rendered by React, so it is HTML-escaped - a post title from your database cannot break out of the head.
How the tags are rendered
- The tags are React elements rendered inside the page's tree; React 19 hoists
<title>,<meta>and<link>into the<head>your root layout renders. Without a root layout they are written into the default document's head. - The hydration envelope carries the same tags, and the browser renders them at the same place: hydration adopts the server's elements, and a soft navigation removes the previous page's tags and inserts the next page's - including tags the next page does not have.
- Metadata supersedes a hand-written title. A
<title>rendered by a component - the root layout, a nested layout or the page itself - next to a metadata title would put two in the head (browsers, crawlers and React all use the first). Every one but the metadata title is removed from the server HTML, and development mode logs a warning. Do not mix the two: a title a page or nested layout renders is mounted again by React once the page hydrates, so the browser tab would end up showing it while crawlers and link previews read the metadata title. Set titles through metadata only - move the root layout's intoexport const metadata = { title: { default: '...' } }. - Only titles are deduplicated. Other tags you hand-write in the root layout (charset, viewport, stylesheets) stay as they are, so do not also declare them in metadata - a
<meta name="description">written in the root layout next to a metadatadescriptiongives the page two, and the hand-written one stays in the head through every navigation. Move it into the root layout'smetadataas well. - Special pages (
not-found.tsx,error.tsx) resolve metadata the same way, so a 404 can sayrobots: 'noindex'. Their layouts'generateMetadataget the params of the route that was not found or failed (none for a URL no route matches). Metadata never stops them rendering: when agenerateMetadatathrows (say the CMS is down - often the very error the error page answers), the special page renders with the staticmetadataexports only, and the failure is logged.
Structured data (JSON-LD)
import { JsonLd } from '@gio.js/react';
export default function Post({ post }: { post: Post }) {
return (
<article>
<JsonLd data={{
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
datePublished: post.publishedAt,
}} />
<h1>{post.title}</h1>
</article>
);
}<JsonLd> renders a <script type="application/ld+json">. The JSON is written with <, >, &, U+2028 and U+2029 escaped, so a value containing </script> cannot end the element. It is a data block the browser never executes, so it needs no CSP nonce.
sitemap.xml, robots.txt and the web manifest
Three files at the root of app/ generate the crawler files. Each default export is the data, or a (sync or async) function returning it:
| File | Serves | Content type |
|---|---|---|
app/sitemap.ts | /sitemap.xml | application/xml |
app/robots.ts | /robots.txt | text/plain |
app/manifest.ts | /manifest.webmanifest | application/manifest+json |
// app/sitemap.ts
import type { MetadataRoute } from '@gio.js/core';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await db.posts.list();
return [
{ url: '/', changeFrequency: 'daily', priority: 1 },
...posts.map(post => ({
url: `/posts/${post.slug}`,
lastModified: post.updatedAt, // Date or string
alternates: { languages: { de: `/de/posts/${post.slug}` } },
})),
];
}
// app/robots.ts
import type { MetadataRoute } from '@gio.js/core';
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/admin', '/api'] },
{ userAgent: 'GPTBot', disallow: '/', crawlDelay: 10 },
],
sitemap: '/sitemap.xml',
};
}- Relative URLs in the sitemap and robots
sitemapresolve againstGIO_SITE_URL(crawlers require absolute URLs). Values are XML-escaped; a robots value containing a line break is rejected rather than written, since it could smuggle in directives of its own. An invalid sitemap entry (nourl, apriorityoutside 0-1, an unknownchangeFrequency) answers 500 with the reason in the log. - The generators get no request context, so their output is the same for everyone and is cached in the Rust cache for
revalidateseconds - 3600 by default (crawlers fetch these a few times a day, and a sitemap often queries every row of a table). Exportrevalidate = 0to generate per request, orfalseto keep the output until the next deploy. - A file of the same name in
public/wins: the server serves it before the request reaches the worker, and logs a startup warning naming the module that never runs. Apage.tsxorroute.tsanswering the same URL fails startup. gio exportwritessitemap.xml,robots.txtandmanifest.webmanifestfrom these modules.
Not yet available
Generated Open Graph images (an opengraph-image.tsx convention rendering JSX to PNG) are not part of GioJS yet. Point openGraph.images at a static file in public/ or at an image your own route.ts produces. A separate viewport export, file-based icons (app/icon.png) and multiple sitemaps (generateSitemaps) are not supported either.