---
name: atlas-seo
title: SEO
description: How Atlas pages get found and shown in search: titles and descriptions (pageMetadata), canonical URLs, sitemaps and honest lastmod, robots, JSON-LD structured data, redirects on slug changes, real 404s, internal links, image SEO, Search Console, Bing Webmaster and IndexNow. Use when adding a public page, changing metadata, titles, URLs or slugs, sitemap, structured data, dates or authors, or when reviewing search visibility.
roles: [editor, frontend]
---

# SEO

Search engines should see exactly what readers see, described once and truthfully.
Every signal (title, canonical, sitemap, JSON-LD, dates) comes from one helper in
`src/features/seo/` or `src/lib/seo/`, and every signal matches the visible page.

## Before you start

- Related skills: `atlas-geo` (AI crawlers, llms.txt, `.md` twins, answer-first writing, place
  signals), `atlas-performance` (LCP image, CDN, speed), `atlas-architecture` (static/ISR rule,
  `routes.*`), `atlas-content` (writing), `atlas-accessibility` (alt text basics).
- Decision record: ADR-021 in `docs/architecture/10-rozhodnuti-adr.md`.
- Tests: `npm run test` (covers `features/seo/*.test.ts`, `lib/seo/*.test.ts`) and
  `npx playwright test tests/seo.spec.ts` (head, structured data, redirects on a running build).
- Detail, page-kind matrix and deep links: [reference](reference.md).

## Rules

### Titles and descriptions
- Every public page's `generateMetadata` returns `pageMetadata({...})`
  (`src/features/seo/metadata.ts`). Static index pages use `staticPageMetadata(section, path)`.
  Never build a `Metadata` object by hand on a public page.
- Every private page (sign-in, account, invitation, checkout, preview, admin) uses
  `privateMetadata(title)`: `noindex, nofollow`.
- Build titles with `pageTitle(main, qualifier?)`. Lead with the entity ("China", "Types of
  Migrants"). The brand suffix is added only when the whole title fits in 60 characters. The H1
  says the same thing as the title.
- Descriptions are unique, human summaries, not keyword lists. `pageMetadata` clamps them to
  `DESCRIPTION_MAX` (160) unless the editor wrote one (`authoredDescription`; DB cap
  `SEO_DESCRIPTION_MAX` 170). Editor SEO titles are capped at `SEO_TITLE_MAX` (70).
- Keep the root robots defaults in `src/app/layout.tsx` (`max-snippet:-1`,
  `max-image-preview:large`). Never add `nosnippet`, `data-nosnippet` or `max-snippet:N`: they also
  cut what AI Overviews can quote.
- Don't spend effort on `keywords`: Google ignores them. The field is harmless.
- `og:type` is `article` for topics and news, `profile` only for authors (people), and the
  default `website` for everything else (countries, regions, issues, indexes).

### Canonical URLs, one language
- The canonical is absolute and self-referencing, produced by `pageMetadata` via `alternates()`
  (`src/lib/seo/index.ts`). Never set or change it in client code, and never vary it by query
  parameter (`?utm_*`, `?view=` keep the clean canonical).
- One URL form everywhere (canonical, sitemap, `og:url`, JSON-LD `url`/`@id`, internal links):
  site host, lowercase, no trailing slash, no `/en` prefix (the proxy 308s `/en/x` to `/x`).
- Build every path with `routes.*` (`src/config/routes.ts`) and make it absolute with
  `absoluteUrl()`. ESLint bans hand-written content paths.
- The site is English only (`LOCALES = ["en"]`). Never add hreflang for other languages and
  don't pass `languages`/`contentLocale` beyond their defaults. The `en` + `x-default`
  self-references that `alternates()` emits are fine; they equal the canonical.
- Never canonicalize with robots.txt or `noindex`.

### Status codes, noindex and redirects
- An unknown slug returns a real 404 (or a redirect) before streaming, through the slug
  layout's `guardedLayout` (`atlas-architecture`). Never render a "not found" view with status
  200: a soft 404 gets indexed as a thin page.
- `noindex` only through `PageSeo.noindex` (the editor's SEO form) for thin or duplicate pages.
  It is in the server HTML or not at all; never add or remove it in client code.
- A published URL never changes. Published articles lock their slug (`EntryForm`). An author
  slug change leaves a permanent redirect automatically (DB trigger `authors_slug_redirect`).
  Any other renamed public slug gets a permanent redirect in
  [Admin → Redirects](https://atlas-of-todays-world.vercel.app/admin/redirects), served as 308
  by `permanentRedirect`. Old URL shapes that code must handle go into `BUILT_IN_REDIRECTS`
  (`src/config/redirects.ts`, `permanent: true` = 308).
- Redirects are one hop and stay forever. When a target moves again, update the old rows to
  the new target instead of chaining.
- robots.txt is generated by `robotsRules()` (`src/lib/seo/robots.ts`). It disallows only
  `/api/`, `/admin`, `/auth/` and `PRIVATE_PATHS`. Never disallow anything a public page needs to
  render: `/_next/`, `/data/`, `/maplibre/`, `/images/`. The bot-by-bot policy lives in `atlas-geo`.

### Sitemaps and lastmod
- `/sitemap.xml` is an index of `/sitemaps/<name>.xml`, one per `SITEMAPS` entry in
  `src/features/seo/queries.ts` (pages, regions, countries, data, news, entries, authors,
  google-news). A new kind of public page is added to `getSitemaps()` in the same PR.
- `lastmod` is when that URL's visible content (text, facts, structured data, links) last
  changed in a meaningful way. Derive it from DB `updated_at` of the row and the children the
  page shows. Never use `Date.now()`, build, deploy or request time. If you don't know the
  real date, leave `lastmod` out: a missing date is better than a false one.
- Bulk changes must not move dates. The `stamp_row()` trigger sets `updated_at := now()` on
  every UPDATE (regions, countries, indicators, special_regions…). A migration that rewrites
  many rows without changing what readers see keeps the old `updated_at`: disable the table's
  `<table>_stamp` trigger around that statement and re-enable it in the same migration
  (see `atlas-backend` for migration rules).
- Don't fold a site-wide stamp into every URL. A country's `lastmod` moves when something shown
  on that country page changed, not when any indicator anywhere changed.
- No `<priority>` or `<changefreq>` (`urlsetXml` doesn't emit them; Google and Bing ignore them).
- Article hero images go in as `image:image` entries. `google-news` lists only news from the last
  two days.

### Dates
- One date source per article: `articleMetaOf(item)` (`src/features/entries/article-meta.ts`)
  feeds Open Graph `article:*`, JSON-LD and the visible dates; the sitemap uses the same row.
- `dateModified`, the visible "Updated" date, `article:modified_time` and `lastmod` move only on
  substantive edits (new facts, rewritten sections, new sources). Not on typo fixes, re-saves,
  imports or migrations. Faking freshness is a spam signal.
- Dates in JSON-LD and `<time datetime>` are full ISO 8601 with time and offset
  (`2026-10-06T09:12:00+00:00`), not a bare `2026-10-06`.

### Structured data (JSON-LD)
- Use only the builders in `src/features/seo/jsonld.ts`, combined with `graph(...)` into one
  `@graph` per page and rendered with `<JsonLd data={...} />` (`src/components/JsonLd.tsx`).
  Never hand-write JSON-LD or another `dangerouslySetInnerHTML` script.
- Use stable `@id`s from `ids.*` (`/#organization`, `/#website`, `{url}#webpage`, `#article`,
  `#place`, `#person`) and cross-reference by `@id` instead of repeating a node.
- Markup describes only what is visible, with the same values: headline, dates, author, numbers.
- Which builder goes on which page: see the matrix in [reference](reference.md#page-kinds).
- Authors: one `Person` per person with `url` to `/authors/<slug>`. `name` holds only the
  person's name. The institution goes in `affiliation`/`worksFor` (an `Organization`), the
  role in `jobTitle`, and both stay visible on the page as their own line. The editorial team
  is the `Organization` (`ids.organization`).
- `Dataset` in full only on `/view/<id>` (`datasetNode`, description 50–5,000 characters,
  licence, creator). Other pages reference it by `@id`.
- `FAQPage` (`faqNode`) only when the questions are visible on the page. Google shows no FAQ
  rich result; Bing and answer engines still read it.
- Country, region and place details (Wikidata `sameAs`, ISO codes, `geo`) follow `atlas-geo`.
- A new or changed builder passes `validateJsonLd` (`src/lib/seo/jsonld-rules.ts`) in its unit
  test, and the page passes the Rich Results Test and the Schema.org validator.

### Internal linking
- Every indexable page is linked from at least one crawlable page: region → its countries,
  issue → its topics, article → author page, author → articles. A sitemap alone is weak.
- Content links are `<Link href={routes.x(...)}>` (a real `<a href>`), never `onClick`
  navigation or a `<button>`.
- Slug pages show `Breadcrumbs` (`src/components/atlas/Breadcrumbs.tsx`) and emit the same trail
  with `breadcrumbNode`.
- Fix what the weekly dead-link check reports (`scripts/check-links.mjs`, workflow
  [links.yml](https://github.com/Atlas-of-Today-s-World/atlas-of-todays-world/actions/workflows/links.yml)).

### Images
- Content photos are real `<img>` elements in the server HTML (`ArticleHero`), not CSS
  backgrounds, so image search indexes them. Sizes, `srcset` and priority: `atlas-performance`.
  Alt text wording: `atlas-accessibility` and `atlas-content` (the hero alt is the page title,
  an owner decision).
- Share images: each slug route has an `opengraph-image.tsx` built with `ogImage()`
  (`src/features/seo/og-image.ts`, 1200×630). Editors can override it per article in the SEO form.

### Search engines
- Verify the site in [Google Search Console](https://search.google.com/search-console?resource_id=https%3A%2F%2Fatlas-of-todays-world.vercel.app%2F)
  and [Bing Webmaster Tools](https://www.bing.com/webmasters/home?siteUrl=https%3A%2F%2Fatlas-of-todays-world.vercel.app%2F)
  with the meta tags from env `GOOGLE_SITE_VERIFICATION`, `BING_SITE_VERIFICATION`
  (`SEZNAM_SITE_VERIFICATION` for Seznam). A `vercel.app` host can't use DNS verification.
  Submit `/sitemap.xml` in both.
- IndexNow: `notifyIndexNow(paths)` (`src/lib/seo/indexnow.ts`) runs after publish, update or
  delete in entry and portrait actions (once per URL per 15 min; only in production with
  `INDEXNOW_KEY`). A new public content type calls it from its save action, with changed URLs only.

## How we do it in Atlas

```tsx
// src/app/[locale]/(map)/news/[slug]/page.tsx (shortened)
export async function generateMetadata({ params }: PageProps<"/[locale]/news/[slug]">) {
  const { slug } = await params;
  const locale = await localeFrom(params);
  const item = await getEntry(slug, locale);
  if (!item) return {}; // the guarded layout already answered 404 or redirect
  return pageMetadata({
    locale,
    path: routes.news(item.slug),
    title: pageTitle(item.title),
    description: item.summary,
    ownImage: true, // the route's opengraph-image.tsx
    type: "article",
    markdown: true, // advertises /news/<slug>.md (atlas-geo)
    article: { ...(await articleMetaOf(item)), authors: item.author ? [item.author] : undefined },
  });
}
```

JSON-LD for the same page: `<JsonLd data={graph(webPageNode({ ..., breadcrumb:
breadcrumbNode(...) }), articleNode({ kind: "news", ... }))} />`.

## Checklist

- [ ] Public page: `pageMetadata` or `staticPageMetadata`; private page: `privateMetadata`.
- [ ] Title leads with the entity, H1 matches; description unique; correct `og:type`.
- [ ] Unknown slug → 404 or redirect via `guardedLayout`; no soft 404.
- [ ] URL built with `routes.*`; renamed slug has a permanent (308) redirect.
- [ ] New page kind is in `getSitemaps()` with a real `lastmod` (or none).
- [ ] No dates bumped by a bulk change or migration.
- [ ] JSON-LD from `jsonld.ts` builders, matches visible text, passes `validateJsonLd`.
- [ ] Author `name` without institution; dates with time and offset.
- [ ] Page reachable through at least one `<a href>` link; breadcrumb present; photos are `<img>`.

## Anti-patterns

- `lastmod`/`dateModified` set to now, to the deploy, or bumped for every topic by a script:
  engines then distrust all our dates.
- Changing a slug without a redirect: links, rankings and AI citations break.
- Hand-written JSON-LD or metadata on one page: it drifts from the shared builders and tests.
- Keyword lists of places in titles, descriptions or text: spam pattern, also hurts GEO.

## Known gaps (2026-10-16)

Found on the live site; fix by the rules above.
1. Sitemap `lastmod` is not trustworthy: all 228 countries share one stamp, all topics share
   2026-10-06, most regions share one stamp, `/countries` and `/global-issues` track recent
   bulk stamps, static pages have none.
2. Topics show a bulk `dateModified` of 2026-10-06 (for example "Types of Migrants", published
   2024-02-01) in the visible date, Open Graph, JSON-LD and sitemap.
3. Author `name` includes the institution ("Name, Organisation") in JSON-LD and on the author
   page. The author model has no `affiliation` field yet (needs an expand-only migration).
4. Article dates are bare dates (`published_on`, `updated_at.slice(0, 10)`), without time or offset.
5. Country pages use `og:type="profile"` (`country/[slug]/page.tsx`); should be `website`.
6. News author is "Atlas editorial team"; use named authors or link the team to a page that
   explains the editorial process.
7. The home page repeats ten full `Dataset` nodes; keep them on `/view/*` and reference by `@id`.
8. IndexNow is off in production (`/indexnow-key.txt` returns 404: `INDEXNOW_KEY` not set) and no
   Google/Bing verification meta is served. Set the env vars in the Vercel project.
9. Legacy tags (`meta keywords`, `WebSite/SearchAction`, Speakable; geo tags see `atlas-geo`)
   are harmless; don't extend them.

## Sources

Key sources: [Google sitemaps and lastmod](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap),
[canonical URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls),
[Article structured data](https://developers.google.com/search/docs/appearance/structured-data/article),
[helpful content](https://developers.google.com/search/docs/fundamentals/creating-helpful-content),
[IndexNow](https://www.indexnow.org/documentation). Full list in [reference](reference.md#sources).
