SEO
Claude skill atlas-seo · for Editor, Frontend
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.
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,.mdtwins, 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(coversfeatures/seo/*.test.ts,lib/seo/*.test.ts) andnpx playwright test tests/seo.spec.ts(head, structured data, redirects on a running build). - Detail, page-kind matrix and deep links: reference.
Rules
Titles and descriptions
- Every public page's
generateMetadatareturnspageMetadata({...})(src/features/seo/metadata.ts). Static index pages usestaticPageMetadata(section, path). Never build aMetadataobject 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.
pageMetadataclamps them toDESCRIPTION_MAX(160) unless the editor wrote one (authoredDescription; DB capSEO_DESCRIPTION_MAX170). Editor SEO titles are capped atSEO_TITLE_MAX(70). - Keep the root robots defaults in
src/app/layout.tsx(max-snippet:-1,max-image-preview:large). Never addnosnippet,data-nosnippetormax-snippet:N: they also cut what AI Overviews can quote. - Don't spend effort on
keywords: Google ignores them. The field is harmless. og:typeisarticlefor topics and news,profileonly for authors (people), and the defaultwebsitefor everything else (countries, regions, issues, indexes).
Canonical URLs, one language
- The canonical is absolute and self-referencing, produced by
pageMetadataviaalternates()(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-LDurl/@id, internal links): site host, lowercase, no trailing slash, no/enprefix (the proxy 308s/en/xto/x). - Build every path with
routes.*(src/config/routes.ts) and make it absolute withabsoluteUrl(). ESLint bans hand-written content paths. - The site is English only (
LOCALES = ["en"]). Never add hreflang for other languages and don't passlanguages/contentLocalebeyond their defaults. Theen+x-defaultself-references thatalternates()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. noindexonly throughPageSeo.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 triggerauthors_slug_redirect). Any other renamed public slug gets a permanent redirect in Admin → Redirects, served as 308 bypermanentRedirect. Old URL shapes that code must handle go intoBUILT_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/andPRIVATE_PATHS. Never disallow anything a public page needs to render:/_next/,/data/,/maplibre/,/images/. The bot-by-bot policy lives inatlas-geo.
Sitemaps and lastmod
/sitemap.xmlis an index of/sitemaps/<name>.xml, one perSITEMAPSentry insrc/features/seo/queries.ts(pages, regions, countries, data, news, entries, authors, google-news). A new kind of public page is added togetSitemaps()in the same PR.lastmodis when that URL's visible content (text, facts, structured data, links) last changed in a meaningful way. Derive it from DBupdated_atof the row and the children the page shows. Never useDate.now(), build, deploy or request time. If you don't know the real date, leavelastmodout: a missing date is better than a false one.- Bulk changes must not move dates. The
stamp_row()trigger setsupdated_at := now()on every UPDATE (regions, countries, indicators, special_regions…). A migration that rewrites many rows without changing what readers see keeps the oldupdated_at: disable the table's<table>_stamptrigger around that statement and re-enable it in the same migration (seeatlas-backendfor migration rules). - Don't fold a site-wide stamp into every URL. A country's
lastmodmoves when something shown on that country page changed, not when any indicator anywhere changed. - No
<priority>or<changefreq>(urlsetXmldoesn't emit them; Google and Bing ignore them). - Article hero images go in as
image:imageentries.google-newslists only news from the last two days.
Dates
- One date source per article:
articleMetaOf(item)(src/features/entries/article-meta.ts) feeds Open Grapharticle:*, JSON-LD and the visible dates; the sitemap uses the same row. dateModified, the visible "Updated" date,article:modified_timeandlastmodmove 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 bare2026-10-06.
Structured data (JSON-LD)
- Use only the builders in
src/features/seo/jsonld.ts, combined withgraph(...)into one@graphper page and rendered with<JsonLd data={...} />(src/components/JsonLd.tsx). Never hand-write JSON-LD or anotherdangerouslySetInnerHTMLscript. - Use stable
@ids fromids.*(/#organization,/#website,{url}#webpage,#article,#place,#person) and cross-reference by@idinstead 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.
- Authors: one
Personper person withurlto/authors/<slug>.nameholds only the person's name. The institution goes inaffiliation/worksFor(anOrganization), the role injobTitle, and both stay visible on the page as their own line. The editorial team is theOrganization(ids.organization). Datasetin 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) followatlas-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>), neveronClicknavigation or a<button>. - Slug pages show
Breadcrumbs(src/components/atlas/Breadcrumbs.tsx) and emit the same trail withbreadcrumbNode. - Fix what the weekly dead-link check reports (
scripts/check-links.mjs, workflow links.yml).
Images
- Content photos are real
<img>elements in the server HTML (ArticleHero), not CSS backgrounds, so image search indexes them. Sizes,srcsetand priority:atlas-performance. Alt text wording:atlas-accessibilityandatlas-content(the hero alt is the page title, an owner decision). - Share images: each slug route has an
opengraph-image.tsxbuilt withogImage()(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
and Bing Webmaster Tools
with the meta tags from env
GOOGLE_SITE_VERIFICATION,BING_SITE_VERIFICATION(SEZNAM_SITE_VERIFICATIONfor Seznam). Avercel.apphost can't use DNS verification. Submit/sitemap.xmlin 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 withINDEXNOW_KEY). A new public content type calls it from its save action, with changed URLs only.
How we do it in Atlas
// 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:
pageMetadataorstaticPageMetadata; 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 reallastmod(or none). - [ ] No dates bumped by a bulk change or migration.
- [ ] JSON-LD from
jsonld.tsbuilders, matches visible text, passesvalidateJsonLd. - [ ] Author
namewithout institution; dates with time and offset. - [ ] Page reachable through at least one
<a href>link; breadcrumb present; photos are<img>.
Anti-patterns
lastmod/dateModifiedset 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.
- Sitemap
lastmodis not trustworthy: all 228 countries share one stamp, all topics share 2026-10-06, most regions share one stamp,/countriesand/global-issuestrack recent bulk stamps, static pages have none. - Topics show a bulk
dateModifiedof 2026-10-06 (for example "Types of Migrants", published 2024-02-01) in the visible date, Open Graph, JSON-LD and sitemap. - Author
nameincludes the institution ("Name, Organisation") in JSON-LD and on the author page. The author model has noaffiliationfield yet (needs an expand-only migration). - Article dates are bare dates (
published_on,updated_at.slice(0, 10)), without time or offset. - Country pages use
og:type="profile"(country/[slug]/page.tsx); should bewebsite. - News author is "Atlas editorial team"; use named authors or link the team to a page that explains the editorial process.
- The home page repeats ten full
Datasetnodes; keep them on/view/*and reference by@id. - IndexNow is off in production (
/indexnow-key.txtreturns 404:INDEXNOW_KEYnot set) and no Google/Bing verification meta is served. Set the env vars in the Vercel project. - Legacy tags (
meta keywords,WebSite/SearchAction, Speakable; geo tags seeatlas-geo) are harmless; don't extend them.
Sources
Key sources: Google sitemaps and lastmod, canonical URLs, Article structured data, helpful content, IndexNow. Full list in reference.
Files of this skill
reference.md
SEO reference
Detail for atlas-seo. Paths are relative to the repository root.
Page kinds
Which metadata and which JSON-LD each kind of public page uses. Every page also gets the
site-wide graph from src/app/[locale]/layout.tsx: organizationNode (NGO with IČO, Wikidata
sameAs, address), websiteNode and builderNode.
| Page | Route | og:type | JSON-LD (builders in src/features/seo/jsonld.ts) |
|---|---|---|---|
| Home | / | website | webPageNode type CollectionPage; Dataset by @id reference (see gap 7) |
| Topic | /topics/[slug] | article | webPageNode + breadcrumbNode, articleNode kind entry (Article), faqNode if FAQ visible |
| News item | /news/[slug] | article | webPageNode + breadcrumbNode, articleNode kind news (NewsArticle) |
| Country | /country/[slug] | website | webPageNode type ItemPage, countryNode (Country) |
| Region | /region/[slug] | website | groupPlaceNode kind region (Place), faqNode if visible |
| Global issue | /global-issue/[slug] | website | groupPlaceNode kind issue, faqNode if visible |
| Author | /authors/[slug] | profile | webPageNode type ProfilePage, personNode |
| Data view | /view/[indicator] | website | datasetNode (full Dataset with licence and creator) |
| Indexes | /topics, /news, /countries, /global-issues | website | webPageNode type CollectionPage or collectionPageGraph, itemListNode |
| About | /about | website | webPageNode type AboutPage |
| Private | account, sign-in, invite, checkout, preview, admin | none | none (privateMetadata) |
Region and issue pages build metadata with groupMetadata(kind) in
src/features/portraits/group-page.tsx.
Article JSON-LD fields we fill
articleNode sets headline (max 110 characters), description, abstract (the "In short"
GEO summary or the summary points), image[], datePublished, dateModified, inLanguage,
isAccessibleForFree, author, publisher (/#organization), mainEntityOfPage,
articleSection, keywords, wordCount, about (countries with Wikidata sameAs),
contentLocation (region), citation[] (the dossier's resources), hasPart[] (subtopics, with
audio), copyrightHolder.
Author target shape (after the affiliation field exists):
{
"@type": "Person",
"@id": "https://atlas-of-todays-world.vercel.app/authors/jane-doe#person",
"name": "Jane Doe",
"url": "https://atlas-of-todays-world.vercel.app/authors/jane-doe",
"jobTitle": "Researcher",
"affiliation": { "@type": "Organization", "name": "Example Foundation" }
}Recommended images for Article: several crawlable images, ideally 16:9, 4:3 and 1:1, each at least 50,000 pixels in area.
Sitemap files
| File | Content | lastmod source (getSitemaps() in src/features/seo/queries.ts) |
|---|---|---|
pages | home, indexes, about, newsletter, membership, legal | latest of the items each page lists |
regions | regions and global issues, with hero image | special_regions.updated_at / regions.updated_at (+ data stamp, see gap 1) |
countries | every country | countries.updated_at (+ data stamp, see gap 1) |
data | /view/<indicator> | indicators.updated_at |
news, entries | articles, with hero image | updated or published of the article |
authors | authors with at least one article | latest article date of the author |
google-news | news from the last 2 days | published |
Route handlers: src/app/sitemap.xml/route.ts (index), src/app/sitemaps/[file]/route.ts.
XML builders: src/lib/seo/xml.ts (sitemapIndexXml, urlsetXml, latest).
Limits: 50,000 URLs and 50 MB per file; split before reaching them.
Redirect sources, in order
BUILT_IN_REDIRECTS(src/config/redirects.ts) vianext.config.tsredirects(): old URL shapes, the former Czech version, old admin bookmarks. Code change + PR.src/proxy.ts:/en/x→/xand case or trailing-slash variants (308).redirectstable managed in Admin → Redirects, read byredirectOrNotFound()in the guarded layouts.permanent→ 308, otherwise 307. Theauthors_slug_redirecttrigger maintains author renames.
Tools and deep links
- Google Search Console (Performance, Pages, Sitemaps): property, Sitemaps report.
- Bing Webmaster Tools: site dashboard (also IndexNow status and the AI Performance report).
- Rich Results Test, Schema.org validator.
- Live files: robots.txt, sitemap.xml.
- Env vars (
INDEXNOW_KEY,GOOGLE_SITE_VERIFICATION,BING_SITE_VERIFICATION,SEZNAM_SITE_VERIFICATION) are runtime env of the Vercel project in team atlas-of-today-s-world → project → Settings → Environment Variables. The IndexNow key is 8–128 charactersa-z A-Z 0-9 -and is public by design. - Editor SEO fields per article: Admin → Content → open the article → SEO (title, description, keywords, "In short (GEO)", share image, noindex).
- Site-wide SEO title, description and share image: Admin → Site settings.
Quick manual checks
curl -sI https://atlas-of-todays-world.vercel.app/country/China # 308 to lowercase
curl -s https://atlas-of-todays-world.vercel.app/country/china | grep -o '<link rel="canonical"[^>]*>'
curl -s https://atlas-of-todays-world.vercel.app/sitemaps/countries.xml | grep -o '<lastmod>[^<]*' | sort | uniq -c | sort -rn | head
curl -s -o /dev/null -w "%{http_code}\n" https://atlas-of-todays-world.vercel.app/country/not-a-country # 404The uniq -c line shows whether many URLs share one lastmod (a sign of bulk or generated dates).
Sources
- Google, JavaScript SEO basics: https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics
- Google, SEO starter guide: https://developers.google.com/search/docs/fundamentals/seo-starter-guide
- Google, canonical URLs: https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls
- Google, localized versions (hreflang): https://developers.google.com/search/docs/specialty/international/localized-versions
- Google, title links: https://developers.google.com/search/docs/appearance/title-link
- Google, snippets: https://developers.google.com/search/docs/appearance/snippet
- Google, supported meta tags: https://developers.google.com/search/docs/crawling-indexing/special-tags
- Google, AI features and preview controls: https://developers.google.com/search/docs/appearance/ai-features
- Open Graph types: https://ogp.me/#types
- Google, build a sitemap (lastmod): https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap
- Google, news sitemap: https://developers.google.com/search/docs/crawling-indexing/sitemaps/news-sitemap
- Google, image sitemap: https://developers.google.com/search/docs/crawling-indexing/sitemaps/image-sitemaps
- Bing, sitemaps and lastmod in AI search (2025): https://blogs.bing.com/webmaster/July-2025/Keeping-Content-Discoverable-with-Sitemaps-in-AI-Powered-Search
- IndexNow documentation: https://www.indexnow.org/documentation
- Google, structured data policies: https://developers.google.com/search/docs/appearance/structured-data/sd-policies
- Google, structured data gallery: https://developers.google.com/search/docs/appearance/structured-data/search-gallery
- Google, Article: https://developers.google.com/search/docs/appearance/structured-data/article
- Google, Breadcrumb: https://developers.google.com/search/docs/appearance/structured-data/breadcrumb
- Google, Organization: https://developers.google.com/search/docs/appearance/structured-data/organization
- Google, ProfilePage: https://developers.google.com/search/docs/appearance/structured-data/profile-page
- Google, Dataset: https://developers.google.com/search/docs/appearance/structured-data/dataset
- Google, Speakable (beta): https://developers.google.com/search/docs/appearance/structured-data/speakable
- Google, helpful content: https://developers.google.com/search/docs/fundamentals/creating-helpful-content
- Google, spam policies: https://developers.google.com/search/docs/essentials/spam-policies
- Google, image SEO: https://developers.google.com/search/docs/appearance/google-images