Metadata Routes
WebJs supports special route files that generate SEO and PWA metadata: sitemaps, robots.txt, web manifest, favicons, and Open Graph images. These files export a function and the framework serves the output at the standard URL.
When to use
- Dynamic sitemaps generated from your database (e.g. all blog post URLs).
- Environment-aware robots.txt (allow everything in production, block staging).
- Dynamic favicons or OG images (e.g. per-post preview images).
When NOT to use
- For an icon or manifest that never changes. Write the plain file at the app root instead (
app/icon.svg,app/apple-icon.png,app/manifest.webmanifest, see App icon and manifest): it is served and linked the same way.
Supported files
Place these at the root of app/ or in any static (non-dynamic) route segment:
| File | Served at | Use case |
|---|---|---|
sitemap.ts | /sitemap.xml | XML sitemap for search engines |
robots.ts | /robots.txt | Crawler directives |
manifest.ts | /manifest.json | PWA web app manifest |
icon.ts | /icon | Dynamic favicon |
apple-icon.ts | /apple-icon | Apple touch icon |
icon.svg / icon.png / icon.ico (app root, static file) | /icon.svg ... | Favicon, linked with its type and size |
apple-icon.png (app root, static file) | /apple-icon.png | Home-screen icon (180x180 PNG) |
manifest.webmanifest / manifest.json (app root, static file) | same name | Web app manifest |
favicon.ico (app root, static file) | /favicon.ico | Legacy favicon, never linked |
opengraph-image.ts | /opengraph-image | OG preview image |
twitter-image.ts | /twitter-image | Twitter card image |
What a metadata route receives
Every metadata route's default export is called with { request, url, siteUrl, pages }. siteUrl is the SITE_URL env var (your public origin) when set, else the request origin. pages() lists every public page of the app as sitemap entries. Type the argument with MetadataRouteContext from @webjsdev/server.
sitemap.ts
The shortest sitemap that stays correct as pages are added returns pages(). A sitemap may return an ARRAY of entries (urls may be paths; they resolve against siteUrl), the same shape Next uses, and the server serializes it:
// app/sitemap.ts
import type { MetadataRouteContext } from '@webjsdev/server';
export default async function sitemap({ pages }: MetadataRouteContext) {
return [...await pages(), { url: '/feed.xml', changeFrequency: 'daily' }];
} pages() includes every static page (route groups dropped), skips any page whose static metadata, or a layout above it, says robots: { index: false }, and includes a dynamic page only when it exports generateSitemapParams(), which lists the params that route serves, usually from a query:
// app/blog/[slug]/page.ts
import { listPostSlugs } from '#modules/blog/queries/list-post-slugs.server.ts';
export async function generateSitemapParams() {
const posts = await listPostSlugs();
return posts.map((p) => ({ params: { slug: p.slug }, lastModified: p.updatedAt }));
} The sitemap() helper from @webjsdev/server turns an array of entries into spec-valid <urlset> XML for you (escaping each URL, formatting lastModified as a W3C datetime, validating priority and changeFrequency). Return its output from the default export.
// app/sitemap.ts
import { sitemap } from '@webjsdev/server';
import { listPostSlugs } from '#modules/blog/queries/list-post-slugs.server.ts';
export default async function () {
const posts = await listPostSlugs();
return sitemap([
{ url: 'https://example.com/', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
...posts.map(p => ({
url: `https://example.com/blog/${p.slug}`,
lastModified: p.updatedAt,
})),
]);
} Each entry is { url, lastModified?, changeFrequency?, priority? }. The url is REQUIRED and XML-escaped (a value with & or < cannot break the document). A malformed entry (no url), an out-of-range priority, or an unknown changeFrequency is dropped rather than emitted as broken XML. The helper is OPTIONAL: you can still return a raw string or a Response for full control.
Sharding a large site (sitemap index)
A single sitemap maxes out at 50,000 URLs. To shard past that, serve each chunk from a route.ts handler and point a root sitemapIndex() at them. Both helpers share the same escaping + date rules.
// app/sitemaps/[shard]/route.ts
import { sitemap } from '@webjsdev/server';
import { listShardUrls } from '#modules/blog/queries/list-shard-urls.server.ts';
export async function GET(req: Request, { params }: { params: { shard: string } }) {
const entries = await listShardUrls(params.shard);
return new Response(sitemap(entries), {
headers: { 'content-type': 'application/xml; charset=utf-8' },
});
} // app/sitemap.ts (the index)
import { sitemapIndex } from '@webjsdev/server';
export default function () {
return sitemapIndex([
{ url: 'https://example.com/sitemaps/posts.xml', lastModified: new Date() },
{ url: 'https://example.com/sitemaps/pages.xml' },
]);
} robots.ts
Return Next's robots object and the server writes robots.txt (the robots() helper from @webjsdev/server is the same serializer, for a route building its own Response). A string or a Response works too.
// app/robots.ts
import type { MetadataRouteContext } from '@webjsdev/server';
export default function robots({ siteUrl }: MetadataRouteContext) {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/account', '/api/'] }],
sitemap: `${siteUrl}/sitemap.xml`,
};
} manifest.ts
// app/manifest.ts
export default function manifest() {
return {
name: 'My App',
short_name: 'App',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#000000',
icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }],
};
} icon.ts and apple-icon.ts
These two are linked into the head for you. Writing the file is the whole wiring, with no <link> to add anywhere:
// app/icon.ts -> serves /icon AND emits the link
export default function Icon() {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">...</svg>';
return new Response(svg, { headers: { 'content-type': 'image/svg+xml' } });
} <!-- what lands in <head> -->
<link rel="icon" href="/icon">
<link rel="apple-touch-icon" href="/apple-icon"> The href carries your basePath, because that is where the route answers. No type or sizes is asserted: a metadata route picks its content type at request time, which is the reason to use one, so declaring a type here could contradict the bytes it serves. Browsers sniff the served type.
Reach for a route when the mark must be computed per request (per theme, per tenant). For a favicon that never changes, the static app/icon.svg below is simpler and cacheable.
App icon and manifest
Every app should carry its own icon. webjs create ships app/icon.svg as a neutral placeholder (a grey tile with a dashed frame) and app/manifest.webmanifest with the app's name. Replace the placeholder with a simple symbol for what the app is, in its own colours, on a 32x32 view box: a filled rounded tile in the primary colour with one bold shape in its foreground colour stays legible at 16 pixels. webjs doctor warns (APP_ICON) while the placeholder is still there.
The static files are served at their own names and linked with what is known about them:
<!-- app/icon.png (192x192), app/icon.svg, app/apple-icon.png, app/manifest.webmanifest -->
<link rel="icon" href="/icon.png" sizes="192x192" type="image/png">
<link rel="icon" href="/icon.svg" sizes="any" type="image/svg+xml">
<link rel="apple-touch-icon" href="/apple-icon.png" sizes="180x180" type="image/png">
<link rel="manifest" href="/manifest.webmanifest"> Raster icons come first, because Google's favicon crawler takes the first usable icon and wants a square raster; a browser that reads SVG picks it regardless. iOS reads only a PNG apple-touch-icon, so add app/apple-icon.png (180x180) for the home screen. When an app has a static icon and an icon.ts route, the file wins the link and the route still serves at /icon. A declared metadata.manifest wins over the manifest file, and manifest: null turns the link off.
/favicon.ico always answers: public/favicon.ico if it exists, else app/favicon.ico, else the app's icon (an .ico or PNG ahead of SVG, then the icon.ts route). Feed readers, bookmark imports and crawlers that read no markup request that path directly.
Declaring icons wins
A metadata.icons declaration replaces the icon files and routes rather than merging with them, the same precedence Next applies to its static icon files. An app that outgrows a placeholder app/icon.ts names its real icons and the route stops being linked, without the file having to be deleted:
// app/layout.ts -> these win; /icon is no longer linked
export const metadata = {
icons: {
icon: [
{ url: '/public/favicon-192.png', type: 'image/png', sizes: '192x192' },
{ url: '/public/favicon.svg', type: 'image/svg+xml', sizes: 'any' },
],
apple: { url: '/public/apple-touch-icon.png', sizes: '180x180' },
},
}; Declare a favicon one of those ways, never as a hand-written <link rel="icon">: only the root layout may write a document shell at all, so a hand-written tag is unavailable to every other layout. A public/favicon.ico needs no declaration either way, since it is served at the origin root for crawlers that read no markup.
opengraph-image.ts and twitter-image.ts
These are linked too, the way Next does it: a page that declares no openGraph.images gets og:image pointing at the nearest opengraph-image route above it (absolute against your site URL), and likewise twitter:image. A nested one answers under its segment, so app/blog/opengraph-image.ts is /blog/opengraph-image and covers the blog pages. A page that declares its own image keeps it.
Page-level metadata
For per-page title, description, and Open Graph tags, export a metadata object from any page.ts. Annotate it with the Metadata type (imported from @webjsdev/core) so a misspelled field or a wrong-typed value is a compile-time error:
// app/blog/[slug]/page.ts
import type { Metadata } from '@webjsdev/core';
export const metadata: Metadata = {
title: 'My Post | Blog',
description: 'A post about webjs',
openGraph: { title: 'My Post', type: 'article' },
}; The SSR pipeline reads metadata and injects <title>, <meta>, and <meta property="og:..."> tags into the HTML head. For a request-scoped title (a dynamic route building its metadata from the loaded record), export an async generateMetadata(ctx) returning Promise<Metadata> instead. See TypeScript for the typed-metadata surface.
Search and share defaults
Set SITE_URL to your public origin (for example https://shop.example.com) in production. It becomes the default metadataBase, and every page then gets, unless it sets its own:
- a
<link rel="canonical">of the site URL plus the page path (the query is dropped). Opt a page out withalternates: { canonical: null }. With no site URL configured, no canonical is emitted, because the request host can be set by a client. og:titleandog:descriptionfromtitleanddescription,og:typewebsite, andog:urlequal to the canonical.twitter:card:summary_large_imagewhen the page has an image, elsesummary.
openGraph and twitter accept Next's shapes: images may be a string, a URL, an object { url, width, height, alt, type }, or an array of these, and emits og:image with its og:image:width / height / alt; camelCase keys become snake_case (siteName is og:site_name); publishedTime, modifiedTime, authors, section and tags emit article:*. So a public page usually needs only a specific title and description, and a private page needs robots: { index: false } (on its section's layout, so every page under it inherits it).
JSON-LD structured data
metadata.jsonLd emits schema.org structured data as one or more <script type="application/ld+json"> blocks in <head>. This is the highest-leverage modern SEO surface (Google's Article, Product, BreadcrumbList, Organization, and FAQ rich results all read it). WebJs stays true to its no-build identity here. JSON-LD is a web standard rendered as a plain script tag, so the framework ONLY serializes and escapes. There is no schema library and no validation, so you own the schema.org object.
A single object emits one script:
import type { Metadata } from '@webjsdev/core';
export const metadata: Metadata = {
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'How webjs ships zero dead JS',
author: { '@type': 'Person', name: 'Vivek' },
datePublished: '2026-06-01',
image: 'https://example.com/og.png',
},
}; An array emits one script PER element, so you can ship several graphs for one page (a Product alongside its BreadcrumbList, say). Per-request data works the same way through generateMetadata, so a dynamic route can build the Article from the loaded record.
import type { Metadata, MetadataContext } from '@webjsdev/core';
export async function generateMetadata(ctx: MetadataContext): Promise<Metadata> {
const post = await getPost(ctx.params.slug); // via a server query
return {
title: post.title,
jsonLd: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
datePublished: post.publishedAt,
author: { '@type': 'Person', name: post.authorName },
},
};
} The serialized JSON is HTML-safe-escaped automatically. <, >, &, and the line separators U+2028 / U+2029 are replaced with their JSON Unicode escapes, so the literal byte sequence </script> can never form in the served HTML (a value containing </script> cannot break out of the script tag). You escape nothing yourself. The block is a NON-EXECUTABLE data island, so a Content-Security-Policy script-src does not gate it and it carries NO nonce. The framework fails SAFE per element: an entry that is not a plain object, or one with a circular reference JSON.stringify cannot serialize, is skipped (with a one-line console.warn) rather than breaking the rest of the head. Absent jsonLd emits nothing.
Connection-warming: preconnect / dnsPrefetch
Warm a cross-origin connection the page is about to use (an API host, a font / image CDN) so the browser pays the DNS + TLS + TCP cost ahead of the first real request (#243):
import type { Metadata } from '@webjsdev/core';
export const metadata: Metadata = {
preconnect: [
'https://api.example.com', // bare URL
{ url: 'https://fonts.gstatic.com', crossorigin: true },// crossorigin set
],
dnsPrefetch: 'https://analytics.example.com', // a single URL
}; - preconnect emits
<link rel="preconnect" href="..." [crossorigin]>, warming DNS + TLS + TCP. Each entry is a URL string or{ url, crossorigin? }(crossorigin: trueemits a barecrossorigin; a string like'anonymous'emits its value). A font CDN needscrossorigin. - dnsPrefetch emits
<link rel="dns-prefetch" href="...">, which resolves DNS only (a lighter-weight precursor that never carriescrossorigin). - Each field takes a URL string, the object form, or an array of either. Every href is HTML-escaped.
Auto vendor preconnect. For an UNPINNED app resolving vendors live from a cross-origin CDN, the framework auto-emits ONE <link rel="preconnect" href="<cdn-origin>" crossorigin> (the resolved vendor CDN origin, e.g. https://ga.jspm.io, derived from the importmap so a --from jsdelivr app preconnects to jsdelivr), so the browser warms that connection before the importmap resolves. It is DEDUPED against an author-declared preconnect to the same origin, and NONE is emitted for a same-origin pinned app (vendors served from the app's own origin) or an app with no cross-origin vendors.
Constraints
- Metadata route files must live at the root or in static segments, not inside
[dynamic]folders. - They are scanned at server startup, not on every request.
- A crawler gets a real 404 status for an unknown URL and for a page that throws
notFound(), so missing records never index as empty pages.
Next steps
- Routing: file conventions for pages and layouts
- Server-Side Rendering: how metadata is injected into HTML
- Deployment: serving metadata in production