Skip to content

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:

FileServed atUse case
sitemap.ts/sitemap.xmlXML sitemap for search engines
robots.ts/robots.txtCrawler directives
manifest.ts/manifest.jsonPWA web app manifest
icon.ts/iconDynamic favicon
apple-icon.ts/apple-iconApple touch icon
opengraph-image.ts/opengraph-imageOG preview image
twitter-image.ts/twitter-imageTwitter 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: true emits a bare crossorigin; a string like 'anonymous' emits its value). A font CDN needs crossorigin.
  • dnsPrefetch emits <link rel="dns-prefetch" href="...">, which resolves DNS only (a lighter-weight precursor that never carries crossorigin).
  • 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