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 static files that never change. Put them in
public/instead (e.g.public/favicon.ico).
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 |
opengraph-image.ts | /opengraph-image | OG preview image |
twitter-image.ts | /twitter-image | Twitter card image |
sitemap.ts
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
// app/robots.ts
export default function robots() {
return {
rules: [{ userAgent: '*', allow: '/' }],
sitemap: 'https://example.com/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' }],
};
}
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.
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.
Next steps
- Routing: file conventions for pages and layouts
- Server-Side Rendering: how metadata is injected into HTML
- Deployment: serving metadata in production