Metadata & SEO
The meta field declares everything that appears in the <head> — title, description, stylesheets, Open Graph tags, and structured data. All metadata is rendered server-side, so crawlers and social media scrapers see the final HTML without executing JavaScript.
Basic metadata
export default {
route: '/about',
meta: {
title: 'About Us — Acme Corp',
description: 'Learn about the team behind Acme Corp.',
styles: ['/app.css'],
},
view: () => `<h1>About Us</h1>`,
}
This generates:
<title>About Us — Acme Corp</title>
<meta name="description" content="Learn about the team behind Acme Corp.">
<link rel="stylesheet" href="/app.css">
All meta fields
| Field | Type | Description |
|---|---|---|
title | string | Page title — appears in the browser tab and search results. |
description | string | Meta description — appears in search engine snippets. Keep under 160 characters. |
styles | string[] | Array of stylesheet URLs — each emits a <link rel="stylesheet"> tag. |
ogTitle | string | Open Graph title. If omitted, falls back to title. |
ogImage | string | Open Graph image URL — shown when the page is shared on social media. |
schema | object | JSON-LD structured data object — emitted as a <script type="application/ld+json"> tag. |
canonical | string | (ctx, serverState) => string | Canonical URL — overrides the auto-derived canonical. Accepts a function for dynamic values. |
Open Graph
Open Graph tags control how the page appears when shared on social media (Twitter/X, Facebook, LinkedIn, Slack, etc.):
meta: {
title: 'My Product — Acme Corp',
description: 'The best product ever made.',
ogTitle: 'My Product', // shorter for social
ogImage: 'https://acme.com/og/my-product.jpg', // 1200×630 recommended
}
Generated tags:
<meta property="og:title" content="My Product">
<meta property="og:description" content="The best product ever made.">
<meta property="og:image" content="https://acme.com/og/my-product.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="My Product">
<meta name="twitter:image" content="https://acme.com/og/my-product.jpg">
ogImage — social media crawlers need the full URL to fetch the image. Recommended size: 1200×630 pixels.Structured data (ld+json)
The schema field accepts a plain object conforming to schema.org vocabulary. Pulse serialises it as a <script type="application/ld+json"> tag in the head:
meta: {
title: 'How to make sourdough — My Blog',
schema: {
'@context': 'https://schema.org',
'@type': 'Article',
headline: 'How to make sourdough',
author: {
'@type': 'Person',
name: 'Jane Smith',
},
datePublished: '2025-01-15',
image: 'https://myblog.com/sourdough.jpg',
},
}
Common schema types:
| @type | Use for |
|---|---|
WebSite | The homepage or site root |
WebPage | General pages |
Article | Blog posts, news articles |
Product | E-commerce product pages |
FAQPage | FAQ pages (enables rich results in Google) |
BreadcrumbList | Breadcrumb navigation |
Organization | Company/brand information |
Canonical URLs
Pulse automatically derives a canonical URL from the request and emits it as a <link rel="canonical"> tag on every page. In most cases no configuration is needed.
Use meta.canonical to override — for example, on paginated pages or when content is accessible at more than one URL. A plain string is resolved once at startup:
// Paginated blog — pages 2, 3, … all canonicalise to the first page
meta: {
title: 'Blog — Page 2',
canonical: 'https://mysite.com/blog',
}
Pass a function to derive the canonical from the request context or server data. The function receives (ctx, serverState):
// Canonical from a URL param
meta: {
canonical: (ctx) => `https://mysite.com/products/${ctx.params.slug}`,
}
// Canonical from a server fetcher result (e.g. canonical slug from a database lookup)
meta: {
canonical: (ctx, serverState) => `https://mysite.com/products/${serverState.product.slug}`,
}
stream: true (the default), the <head> is written before server fetchers resolve, so serverState will be null for streaming responses. If your canonical depends on server data, set stream: false on that spec, or derive it from ctx.params instead.Sitemap & robots.txt
Pulse generates /sitemap.xml and /robots.txt from the registered routes — enable it with one option:
await createServer(pages, {
sitemap: true, // or { origin: 'https://mysite.com' } to pin the origin
})
Static page routes are included automatically. Excluded by default: the route: '*' not-found page, raw content specs, guarded pages (set sitemap: true on the spec to opt one in), and any page with sitemap: false.
Dynamic :param routes can't be enumerated automatically — give them a sitemap enumerator or they are skipped (the server logs a startup hint listing them):
export default {
route: '/blog/:slug',
sitemap: async () => {
const posts = await db.posts.allPublished()
return posts.map(p => ({ path: `/blog/${p.slug}`, lastmod: p.updatedAt }))
},
// entries are '/path' strings or { path, lastmod } objects
}
| Where | Value | Effect |
|---|---|---|
createServer | sitemap: true | { origin } | Enables generation. Origin defaults to the request host; pin it for multi-domain setups. |
createServer | robots: false | string | Disable robots.txt, or serve a custom string verbatim. Default auto-generates allow-all + the Sitemap line. |
| spec | sitemap: false | Exclude this page. |
| spec | sitemap: true | Include a guarded page that would be excluded by default. |
| spec | sitemap: array | function | Enumerate URLs — required for dynamic routes. |
sitemap.xml or robots.txt in staticDir always wins over the generated one — drop a file in public/ to take full manual control. For fully hand-built XML, see Raw Responses.Stylesheets
The styles array accepts any number of stylesheet URLs. They are emitted as <link rel="stylesheet"> tags in the <head> in the order declared:
meta: {
styles: [
'/app.css',
'/fonts.css',
'https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap',
],
}
Personality presets (meta.vibe)
Set meta.vibe to apply a personality preset that adjusts typography, border radius, and letter-spacing across all components on the page. The preset sets a data-vibe attribute on <body> — pair it with a theme.css for complete brand expression.
export default {
route: '/landing',
meta: {
vibe: 'playful',
styles: ['/pulse-ui.css', '/theme.css'],
},
}
| Vibe | Character | Best for |
|---|---|---|
warm | Friendly, rounded (14px), humanist feel | Local businesses, wellness, food, services |
editorial | Serif display, zero radius, tight tracking | Publications, blogs, journalism, literary |
playful | Extra-large radius (22px), loose spacing | Consumer apps, kids, lifestyle, creators |
minimal | Ultra-low radius (2px), refined whitespace | Portfolios, luxury, developer tools |
bold | Tight tracking, strong presence | Agencies, startups, sports |
brutalist | Zero radius, oversized Arial Black / Impact headings | Art, fashion, counter-culture, zines |
retro | Slab serif, medium radius (8px), nostalgic geometry | Craft, breweries, vintage, heritage brands |
corporate | Conservative radius (4px), tight tracking, professional | B2B, finance, legal, consultancies |
neon | Monospace headline, futuristic, sharp edges | Gaming, crypto, dark-tech, cyberpunk |
paper | Organic radius (10px), serif body, tactile feel | Journals, bookshops, newsletters, diaries |
theme.css that defines --ui-accent, --color-bg, and other colour tokens to complete the personality.SEO tips
- Write a unique
titleanddescriptionfor every page — duplicate metadata prevents pages from competing in search results. - Keep descriptions under 160 characters — longer values are truncated.
- Use structured data to qualify for Google rich results.
- All metadata is in the server-rendered HTML — search engines and social scrapers do not need to execute JavaScript to read it.
- Pulse targets 100/100 Lighthouse SEO out of the box. Run
/pulse-reportafter new pages to confirm.