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 17.4)

The <head> metadata is opt-in per page: render %seo->head where you want it (17.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.2.1 lastmod

An entry in %app->pages may be a plain uri string, or an object carrying that uri plus what else the page has to say:

prop pages => array_merge(
	[void, '/install', '/pricing'],
	array_map(fn($p) => obj(uri: '/blog/'.$p->slug, lastmod: $p->date), %blog->posts),
)

The value may be a Y-m-d date, a full timestamp, or a unix time. Where it comes from is yours to decide: a field on a record, a date in front matter, the modification time of a source file.

For pages whose date is not in your data, the lastmod resource works it out at build time. It resolves each uri in %app->pages to the class that renders it (/pricing to pricing.phlo or page.pricing.phlo), stamps a map next to your generated PHP, and reads it at runtime. Name the exceptions yourself:

prop %lastmod.sources => ['/server-setup' => app.'server.setup.phlo']

Run it from a build hook in data/app.json, so the dates are read where the sources are:

"runAfter": ["php %app/www/app.php lastmod::stamp"],
"release": { "runAfter": ["php %app/www/app.php lastmod::stamp %app/release/"] }

That map is also what a documentation page reads to print "last updated" for its reader, which is usually the better reason to have it.

Lesson. Do not resolve the date at runtime from a file. The assumption "the file is as old as its content" holds on the machine you edit on and breaks everywhere else: a release node carries no .phlo sources at all, and any deploy that copies or clones rewrites every modification time, so every page would claim to have changed on the day it was deployed. Stamp at build time, ship the map. And leave the field off where you have nothing real: a crawler that catches a site inventing them stops trusting it for the whole domain, which costs more than the pages you were trying to help.

17.3: The head block

%seo->head renders the conventional metadata set, all derived from existing props:

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: The indexable constant

One constant decides whether a host belongs in a search index at all, and it is safe by default. Unless the app sets indexable 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.

Leaving the constant out does three things at once, and only the last needs the resource:

Where Without indexable
Every response the app renders X-Robots-Tag: noindex, so JSON and generated downloads are covered too
robots.txt User-agent: * and Disallow: /
%seo->head <meta name=robots content=noindex,follow>, and no canonical link

The header is set by the response itself rather than by the resource, so an app that never loads seo is still kept out of the index. It reaches exactly as far as PHP does: a file the web server hands over on its own, such as a stylesheet or an image sitting in the web root, never passes through the app and carries no header. Where that matters, the server config is the place to add one.

A Disallow keeps a crawler away from the page, which also means it never reads the noindex on it, so an address that is linked somewhere can linger in the index without content. When the point is to have a page removed, invite the crawl and let the header do the work:

method %seo.robots => 'User-agent: *'.lf.'Allow: /'.lf

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
prop %seo.ogType = 'article'
prop %seo.siteName = 'Example Co'

twitterCard opts in to the Twitter summary card, ogType overrides the Open Graph type, and siteName overrides the site name (default: %app->title, falling back to 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. An error response needs no flag at all: anything from 400 upwards is noindex on its own, so a not-found page never has to remember.

For alternates an app renders itself, %seo->noLink reads back the same decision: it is true on a page without a public address and on every host without indexable, so a dev host stops advertising language variants.


Reference. The seo and lastmod resources are documented per node in the Manual, generated from the resource files so it never drifts from the code.

Last updated on 21 September 2026

We use essential cookies to make this site work. With your permission we also use analytics to improve the site.