Head & SEO
Title, canonical, Open Graph, Twitter, JSON-LD, hreflang, feeds, sitemap and OG images.
How the head works
Each route's .head(ctx) returns a small object: title, description, canonical, image
and extra elements. The head plugin composes the full <head> from it, in this order:
- Title.
head.titleTemplatewith%sreplaced by the route title, or the site name. - Description. The route description, or
site.description. - Open Graph and Twitter. Title, description, URL, and
og:image/twitter:imagefrom the routeimageorhead.defaultOgImage, made absolute againstsite.url.og:localewheni18n.ogLocaleMaphas the page locale. - Canonical. The route
canonical, orsite.urljoined with the page URL. - hreflang. One alternate link per locale, plus
x-default. - Your elements. The route
elements, last. An element with the same key as a generated one replaces it.
The build writes this into every page. On client navigation spa copies the head from the
fetched page, or sets the title through head.composeTitle on the data path. A head element is
a plain object, not a Preact node, so the same descriptors serialize at build and apply in the
browser.
The build plugin adds the site-wide files: feeds, sitemap.xml, robots.txt and OG images.
Example
import { buildPlugin, contentPlugin, createApp, fileSystemContent } from "@moku-labs/web";
import { routes } from "./routes";
export const app = createApp({
plugins: [contentPlugin, buildPlugin],
pluginConfigs: {
site: { name: "My Blog", url: "https://blog.dev", author: "Ada", description: "Notes." },
head: {
titleTemplate: "%s · My Blog",
defaultOgImage: "/og-default.png",
twitterCard: "summary_large_image",
twitterHandle: "@moku_labs"
},
i18n: { locales: ["en", "uk"], defaultLocale: "en", ogLocaleMap: { en: "en_US", uk: "uk_UA" } },
router: { routes },
content: { providers: [fileSystemContent({ contentDir: "content" })] }
}
});- Must contain
%s, orcreateAppthrows.
import { buildArticleHead, contentPlugin, defineRoutes, feedLink, route } from "@moku-labs/web/browser";
export const routes = defineRoutes({
post: route("/{slug}/")
.load(ctx => ctx.require(contentPlugin).load(ctx.params.slug, ctx.locale))
.render(ctx => <article dangerouslySetInnerHTML={{ __html: ctx.data.html }} />)
.head(ctx => ({
title: ctx.data.frontmatter.title,
description: ctx.data.frontmatter.description,
image: `/og/${ctx.data.computed.slug}.png`,
elements: [
...buildArticleHead(
{
title: ctx.data.frontmatter.title,
author: ctx.data.frontmatter.author ?? "Ada",
published: ctx.data.frontmatter.date,
tags: ctx.data.frontmatter.tags
},
`https://blog.dev${ctx.data.url}`
),
feedLink("My Blog", "/feed.xml")
]
}))
});- Relative is fine here: the head plugin makes it absolute.
- Canonical,
og:type=article, published time, author, tags and an Article JSON-LD block.
What the build writes
bun run build ▟▙ moku web build v2.4.11 ✓ pages · 5ms ✓ feeds · 2ms ✓ sitemap · 1ms ✓ BUILD · 9 pages 1252ms · ./dist/ cat dist/robots.txt User-agent: * Allow: / Sitemap: https://blog.dev/sitemap.xml
The head of /uk/hello-world/, as the build wrote it (one tag per line here):
<title>Привіт, світ · My Blog</title>
<meta name="description" content="Перший допис.">
<meta property="og:title" content="Привіт, світ">
<meta property="og:description" content="Перший допис.">
<meta property="og:url" content="https://blog.dev/uk/hello-world/">
<meta name="twitter:card" content="summary_large_image">
<link rel="canonical" href="https://blog.dev/uk/hello-world/">
<link rel="alternate" hreflang="en" href="https://blog.dev/hello-world/">
<link rel="alternate" hreflang="uk" href="https://blog.dev/uk/hello-world/">
<link rel="alternate" hreflang="x-default" href="https://blog.dev/hello-world/">
<meta name="build-id" content="1793577742510-06dbe81d-2692-4613-a480-605d41931010">
<link rel="stylesheet" href="/assets/main-gzka1ns2.css">
<script type="module" src="/assets/client-7wyejjw7.js"></script>The build-id meta and the asset tags are added by the build, after the head plugin. The sitemap lists the canonical URLs
only, so the /en/… copies of default-locale pages are not in it.
Head helpers
Plain functions that return head elements. Use them in elements. Each one is exported from
both entries.
| Helper | Signature | Emits |
|---|---|---|
meta | meta(name, content) | <meta name content>, key meta:<name> |
og | og(property, content) | <meta property content>, key meta:<property> |
twitter | twitter(name, content) | <meta name content>, key meta:<name> |
jsonLd | jsonLd(data) | <script type="application/ld+json">, with <, > and & escaped |
canonical | canonical(url) | <link rel="canonical">, key link:canonical |
hreflang | hreflang(locale, url) | <link rel="alternate" hreflang>, key link:alternate:<locale> |
feedLink | feedLink(title, url, type?) | <link rel="alternate">, RSS by default |
buildArticleHead | buildArticleHead(meta, canonicalUrl) | The article set: canonical, og:type, article:* tags, JSON-LD |
To override a generated tag, return an element with the same key. og("og:title", "Shorter")
replaces the generated og:title.
Site-wide outputs
pluginConfigs: {
head: { defaultOgImage: "/og-default.png" },
build: {
feeds: true,
sitemap: true,
ogImage: { fontDir: "fonts", defaultCard: true }
}
}- Points every page without its own image at the card the build renders.
feed.xml,atom.xmlandfeed.jsonfrom the content articles.sitemap.xmlandrobots.txt.- One card per article in
og/<slug>.png, plusog-default.png.
ogImage renders with Satori and needs a folder with at least one .ttf, .otf or .woff
font. defaultCard also takes a function (input) => VNode to draw your own site card. A card
is rendered again only when its article's title, the template or the size changed.
titleTemplateneeds%s. Without itcreateAppthrows[web] head: titleTemplate must contain the "%s" token (replaced by the route title), received "My Blog".site.urlmust be the production origin. Canonical,og:url, relative images, the sitemap and the feeds are all built from it.buildArticleHeaddoes not resolve relative images. Itsimagegoes toog:imageas you pass it. Pass an absolute URL.og:localeneedsogLocaleMap. Without a mapping for the page locale the tag is left out.ogImage.fontDiris checked atcreateApp. A missing folder, or one with no font file, fails before the build starts.
Related
- head plugin: config,
render,siteHead,composeTitle. - build plugin:
feeds,sitemap,ogImageand the other outputs. - i18n: hreflang and
og:locale.