SEO and metadata
Every page's
<head>
is assembled declaratively at build time, with no imperative code. Metadata comes from three places (
project.json
, the layout, the page), template strings pull values from state, and the build adds a sitemap and
robots.txt
on top.
Page-level
$head
A page declares metadata as an array of head elements under
$head
, and its title as a top-level
title
property:
{
"title": "My Blog Post — My Site",
"$head": [
{
"tagName": "meta",
"attributes": {
"name": "description",
"content": "A great blog post about things"
}
}
]
}
Each entry is
{ "tagName": ..., "attributes": ... }
, and any head element works:
meta
,
link
,
script
,
style
. Use the
title
property rather than a
<title>
entry; the merge always writes the computed title last, so a literal
<title>
in
$head
is overridden.
Templated metadata
title
and
$head
attribute values support template strings, evaluated against the page's resolved state. Content-driven pages take their metadata straight from the content entry, with no duplication. This is the docs page template on jxsuite.com:
{
"$paths": { "contentType": "docs", "param": "slug", "field": "id" },
"title": "SEO and metadata — Jx Suite",
"$head": [
{
"tagName": "meta",
"attributes": {
"name": "description",
"content": "Declare page metadata with $head, template it from state, and let the build merge heads and emit sitemap.xml and robots.txt."
}
}
]
}
The injected site and page context is available too:
Jx Suite
,
https://jxsuite.com
,
/docs/framework/site/seo
, and route params via
framework/site/seo
.
Merge order
The build assembles each page's
<head>
from four layers, later entries winning:
Built-in defaults :
<meta charset>and a standard viewport tagSite :
$headinproject.json(favicon, fonts, global meta)Layout : the layout document's
$headPage : the page's
$head
Duplicates are detected by element identity, so a page-level entry replaces the site-level one rather than appearing twice:
| Element | Deduplication key |
|---|---|
<title>
,
<meta charset> |
singleton |
<meta name="..."> |
name |
<meta property="..."> |
property
(Open Graph) |
<link rel="..."> |
rel
+
href
+
hreflang
/
type
/
media
/
sizes |
<script src="..."> |
src |
Links carry that fourth part because
rel
and
href
alone are not enough to tell two links apart: an RSS and an Atom feed are both
rel="alternate"
at different
type
s, and two favicon sizes share everything but
sizes
.
A misspelled
rel
gets a warning
A
<link>
with a typo'd relation is a special kind of frustrating: it's still valid HTML, it still renders, and it does nothing.
<link rel="stylshet"> — "stylshet" is not an IANA link relation, and a relation nobody
recognizes does nothing. Check the spelling, or use an absolute URI if it is an extension
relation (RFC 8288 §2.1.2).
You get one warning per distinct value, however many pages carry it, because the ones that matter live in the site or layout
$head
, so they're on every page.
It's a warning and never an error, and three things never trigger it: any relation in the
IANA registry
, the legacy
shortcut
in
rel="shortcut icon"
, and any absolute URI, which is how RFC 8288 says to write a relation the registry doesn't carry:
{ "tagName": "link", "rel": "https://example.com/rel/pricing", "href": "/pricing/" }
Two tags are added automatically:
<link rel="canonical">
(built from
url
in
project.json
plus the page route, when
url
is set) and the
<html lang>
attribute. Both lose to one you write yourself. If your
url
includes a folder, as in
https://example.com/docs/
, the folder is kept: the canonical for
/about
is
https://example.com/docs/about
, not
https://example.com/about
. See
serving from a subfolder
.
lang
comes from the page's
$lang
if it has one, otherwise
defaults.lang
, otherwise
"en"
. A page can also set
$dir
(or the site
defaults.dir
) for right-to-left content:
{
"$lang": "ar-EG",
"$dir": "rtl"
}
dir
is omitted entirely when neither is set. Nothing is guessed. On a site with
locales
configured, both are derived from the route's language, and translated pages also gain
rel="alternate"
links pointing at each other.
Package files in
$head
A
$head
entry can point at a file inside an installed package by its bare specifier instead of a URL:
{
"$head": [
{
"tagName": "link",
"attributes": {
"rel": "stylesheet",
"href": "@shoelace-style/shoelace/dist/themes/light.css"
}
}
]
}
The build resolves the specifier against your project root and copies the file into
/assets/
, rewriting the tag to point there. The name is derived from the specifier, so it is the same on every build:
<link rel="stylesheet" href="/assets/shoelace-style-shoelace-dist-themes-light.css" />
$elements
entries are handled the same way except that they are bundled instead of copied, because a component package imports its own dependencies and those imports have to be resolved before the browser sees them.
If the package is not installed, the build fails and names the specifier. It does not emit a link and hope: a dead stylesheet URL looks identical to a working one until the site is deployed.
Structured data
A
<script type="application/ld+json">
entry takes an
object
as its
textContent
, and the build serializes it, so you do not write JSON inside a string:
{
"$head": [
{
"tagName": "script",
"attributes": { "type": "application/ld+json" },
"textContent": {
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": ,
"author": { "@type": "Person", "name": "Jx Suite" }
}
}
]
}
Template strings resolve inside the object, at any depth, so the block can reference the page it describes. Jx emits the JSON-LD as written and does not process it: no context expansion, no validation against schema.org.
Sitemap
When
url
is set in
project.json
, the build emits
dist/sitemap.xml
from the route table, one
<url>
per compiled page, with:
<loc>: absolute, built fromurl+ the route, identical to the page's canonical URL, folder included<lastmod>: a full timestamp (2025-03-04T16:00:00Z), taken from the page source file, or from the content entry when the page was generated from one
Dynamic routes appear as their expanded concrete URLs, each dated by
its own content entry
rather than by the
[slug]
template. That matters more than it sounds: you edit a template far more often than the posts under it, and dating by the template made every post in an archive announce itself as changed each time, the opposite of what
<lastmod>
is for. A route with no entry behind it (an authored page, or a
$paths
listing plain values) is still dated by its own file.
Redirect sources are not pages and never appear.
To opt a single page out (a thank-you page, a draft), set
"$sitemap": false
at the page root. To disable the sitemap entirely, set
"build": { "sitemap": false }
. Without
url
the sitemap is skipped with a build warning, because absolute
<loc>
values can't be built.
robots.txt
After
public/
is copied into
dist/
, the build appends a
Sitemap: <url>/sitemap.xml
line to
dist/robots.txt
. If you shipped no
robots.txt
, a permissive default is created (
User-agent: *
/
Allow: /
); if yours already has a
Sitemap:
line, it is left untouched.
Related
project.json : site-level
$head,url, anddefaultsLayouts : where layout-level head entries come from
Content collections : the entry data that feeds templated metadata