Skip to content
All skills

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, .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.

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, 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 @ids 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.
  • 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).

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 and Bing Webmaster Tools 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

// 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, 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.

PageRouteog:typeJSON-LD (builders in src/features/seo/jsonld.ts)
Home/websitewebPageNode type CollectionPage; Dataset by @id reference (see gap 7)
Topic/topics/[slug]articlewebPageNode + breadcrumbNode, articleNode kind entry (Article), faqNode if FAQ visible
News item/news/[slug]articlewebPageNode + breadcrumbNode, articleNode kind news (NewsArticle)
Country/country/[slug]websitewebPageNode type ItemPage, countryNode (Country)
Region/region/[slug]websitegroupPlaceNode kind region (Place), faqNode if visible
Global issue/global-issue/[slug]websitegroupPlaceNode kind issue, faqNode if visible
Author/authors/[slug]profilewebPageNode type ProfilePage, personNode
Data view/view/[indicator]websitedatasetNode (full Dataset with licence and creator)
Indexes/topics, /news, /countries, /global-issueswebsitewebPageNode type CollectionPage or collectionPageGraph, itemListNode
About/aboutwebsitewebPageNode type AboutPage
Privateaccount, sign-in, invite, checkout, preview, adminnonenone (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

FileContentlastmod source (getSitemaps() in src/features/seo/queries.ts)
pageshome, indexes, about, newsletter, membership, legallatest of the items each page lists
regionsregions and global issues, with hero imagespecial_regions.updated_at / regions.updated_at (+ data stamp, see gap 1)
countriesevery countrycountries.updated_at (+ data stamp, see gap 1)
data/view/<indicator>indicators.updated_at
news, entriesarticles, with hero imageupdated or published of the article
authorsauthors with at least one articlelatest article date of the author
google-newsnews from the last 2 dayspublished

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

  1. BUILT_IN_REDIRECTS (src/config/redirects.ts) via next.config.ts redirects(): old URL shapes, the former Czech version, old admin bookmarks. Code change + PR.
  2. src/proxy.ts: /en/x → /x and case or trailing-slash variants (308).
  3. redirects table managed in Admin → Redirects, read by redirectOrNotFound() in the guarded layouts. permanent → 308, otherwise 307. The authors_slug_redirect trigger maintains author renames.

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   # 404

The 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

This site uses only the cookies it needs to work — no advertising or tracking cookies. Visits are counted without cookies. Privacy