GioJSdocs
On this page

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.

tsx
// 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 template set by a layout above the segment is applied (%s is 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.
  • null removes 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:

app/posts/[slug]/page.tsx
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' generateMetadata get the context only (no props); 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 in getServerSideProps: a page exporting revalidate is not cached, with a warning. Unlike in getServerSideProps, this also holds for shell = '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. There getServerSideProps may read credentials, because its props stream after the shell - but a title built from those props would land in the shared shell. So when getServerSideProps read credentials and generateMetadata takes props, the shell is not cached (the page streams without it, with a warning). On such pages, build metadata from ctx.params/ctx.query - fetching by slug again if needed - and leave props alone. Pages whose getServerSideProps reads nothing personal keep using props freely.
  • 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

FieldRenders
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>
openGraphog: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)
twittertwitter:card, :site, :creator, :title, :description, :image (+ :alt)
iconsA 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 into export 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 metadata description gives the page two, and the hand-written one stays in the head through every navigation. Move it into the root layout's metadata as well.
  • Special pages (not-found.tsx, error.tsx) resolve metadata the same way, so a 404 can say robots: 'noindex'. Their layouts' generateMetadata get the params of the route that was not found or failed (none for a URL no route matches). Metadata never stops them rendering: when a generateMetadata throws (say the CMS is down - often the very error the error page answers), the special page renders with the static metadata exports only, and the failure is logged.

Structured data (JSON-LD)

tsx
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:

FileServesContent type
app/sitemap.ts/sitemap.xmlapplication/xml
app/robots.ts/robots.txttext/plain
app/manifest.ts/manifest.webmanifestapplication/manifest+json
ts
// 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 sitemap resolve against GIO_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 (no url, a priority outside 0-1, an unknown changeFrequency) 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 revalidate seconds - 3600 by default (crawlers fetch these a few times a day, and a sitemap often queries every row of a table). Export revalidate = 0 to generate per request, or false to 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. A page.tsx or route.ts answering the same URL fails startup.
  • gio export writes sitemap.xml, robots.txt and manifest.webmanifest from 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.