17: SEO
The seo resource centralises the search and sharing metadata an app needs: sitemap.xml, robots.txt, hreflang alternates, the canonical link, and a <head> block of <meta> description plus Open Graph and Twitter cards. It derives everything from props you already set, so a uniform, complete-but-restrained set ships from one place.
17.1: Activation
Add the resource (it needs output):
{
"resources": [..., "seo", "output"]
}
That alone serves two routes:
| Route | Output |
|---|---|
GET /sitemap.xml |
A multilingual sitemap built from %app->pages and %app->langs |
GET /robots.txt |
Allow or Disallow, gated on the indexable constant (see X.4) |
The <head> metadata is opt-in per page: render %seo->head where you want it (X.3).
17.2: The sitemap and hreflang
The sitemap iterates %app->pages and, for each page, emits an hreflang alternate per entry in %app->langs plus an x-default. Localised paths come from %app->slugs (a uri => localised-uri map) when you have them; otherwise the path is prefixed with the language code.
In your <head>, the same alternates belong as <link rel=alternate hreflang> tags. The resource gives you one view for that, so you loop your languages and let seo format each link:
view head:
<foreach array_keys(%app->langs) AS $lang>
{{ %seo->link($lang, $lang === %app->lang ? %req->uri : "/$lang".%req->uri) }}
</foreach>
{{ %seo->head }}
The app owns which URLs are alternates (it knows its own routing and localisation); seo owns the markup.
17.3: The head block
%seo->head renders the conventional metadata set, all derived from existing props:
<meta name=description>(only when a description is present);- Open Graph:
og:site_name,og:title,og:description,og:type,og:url,og:image,og:locale; - a
<link rel=canonical>(dropped when the page isnoindex); - a Twitter summary card, opt-in (X.5);
<meta name=robots content=noindex,follow>when the page opts out (X.5).
It is composable, not a replacement: keep your app's own head tags (title, CSRF, styles) and add %seo->head alongside them. There are no keywords and no marketing filler; the set is Open Graph, Twitter card, canonical, hreflang, description and robots, which is the usual complete-but-restrained baseline.
The defaults read from props you already have:
| Property | Default source |
|---|---|
og:title |
The document title |
og:description / description |
%app->description |
og:image |
%app->image, falling back to /icon.webp at the site root |
og:url / canonical |
The current request URL |
og:site_name |
The app id |
og:locale |
Derived from %app->lang (for example nl becomes nl_NL) |
og:type |
website |
17.4: robots.txt and the indexable constant
robots.txt is generated, and it is safe by default. Unless the app sets the indexable constant to a truthy value, the resource serves:
User-agent: *
Disallow: /
So dev, stage and any non-public host are de-indexed simply by not declaring indexable. Set it only on the real production entrypoint, where you also set the real host:
phlo_app(
id: 'Example',
host: 'example.com',
indexable: true,
);
With indexable: true the resource serves an Allow: /, one Disallow: line per entry in %app->robotsDisallow, and a Sitemap: line. Because robots.txt comes from the route, there is no static robots.txt to maintain or to accidentally deploy from a dev host.
17.5: Per-app and per-page overrides
The defaults cover most apps. Override the rest with the cross-class injection idiom (see the Advanced chapter), which sets the prop at build time:
prop %seo.twitterCard = true // opt in to the Twitter summary card
prop %seo.ogType = 'article' // override the Open Graph type
prop %seo.siteName = 'Example Co' // override the site name (default: the app id)
Two opt-outs are read per request from app props, so a single page can drop out of the index without touching the rest of the site:
| Property | Effect |
|---|---|
%app->image |
The default Open Graph image for the whole app |
%app->noindex (or %app->noLink) |
Marks the current page noindex,follow and drops its canonical link |
Set %app->noindex in a route before rendering to keep that one page out of search results while the rest of the app stays indexable.