SEO Guide for Static Next.js Sites

Metadata, Open Graph, JSON-LD, sitemaps, and AI discoverability.

View raw Markdown

SEO Guide for Static Next.js Sites

A practical, end-to-end guide to building search, social, and AI discoverability into a static Next.js (App Router) site. Every pattern below is illustrated with real code from Puzzle Games — a fully static puzzle archive — but the techniques apply to any content-heavy site.

Stack assumed: Next.js App Router, TypeScript, static export or SSG, Vercel (or similar).

What you will build:

  • Consistent page metadata (title, description, canonical, Open Graph, Twitter)
  • Dynamic Open Graph images with call-to-action buttons
  • JSON-LD structured data (breadcrumbs, FAQ, articles, lists)
  • SEO intent landing pages for high-value queries
  • Split sitemaps, robots.txt, and indexing automation
  • llms.txt for AI agent discoverability

Table of contents

  1. Best things to do
  2. Architecture
  3. Central metadata helper
  4. Titles and descriptions
  5. Path-aware keywords
  6. Dynamic Open Graph images
  7. JSON-LD structured data
  8. SEO intent landing pages
  9. Breadcrumbs
  10. Sitemaps and robots.txt
  11. LLM / AI discoverability
  12. Indexing after deploy
  13. Search engine verification
  14. Cookbook: add SEO to a new page
  15. Cookbook: add SEO to a new content type
  16. Testing and debugging
  17. Reference file map

Best things to do

Start here. These are the highest-impact SEO tasks for a static Next.js site, in the order we recommend implementing them. Each item links to the detailed section below.

1. Centralize metadata on every page

Do: Wrap every page's metadata export in a single helper (pageMetadata) that sets canonical URL, Open Graph, Twitter card, and keywords automatically.

Why: Without this, pages silently miss canonicals, use inconsistent social tags, or fall back to a generic og:image.

Example (Puzzle Games): /archive/linkedin-puzzle-archive uses pageMetadata() with a custom OG image; /privacy-policy uses the same helper with defaults.

→ §2 Central metadata helper


2. Write unique titles and short descriptions

Do: Keep title and description builders in dedicated files (page-titles.ts, page-descriptions.ts). Aim for page titles around ~60 characters (Google SERP best practice — longer titles are fine but may truncate). Cap meta descriptions at ~125 characters so social previews do not truncate mid-sentence.

Why: Google and social platforms show your title + description in search results and link previews. Generic or truncated copy hurts click-through rate.

Example: Puzzle play title — "LinkedIn Crossclimb #781 – Hints, Answer & Solution" (no suffix — over 60 chars with | Puzzle Games). Game landing description — "Play Queens free online. Place one queen per row, column, and region without touching." (~95 chars)

→ §3 Titles and descriptions


3. Generate dynamic Open Graph images with a CTA button

Do: Create opengraph-image.tsx routes that render 1200×630 PNG cards with a headline, context, and a high-contrast CTA pill (Play Now, Solve Now, etc.).

Why: Branded share cards with a visible call-to-action significantly improve click-through when links are shared on LinkedIn, X, Slack, and messaging apps.

Example (Puzzle Games):

URLOG imageCTA
/Site defaultPlay Now
/queensGame landing + board previewSolve Now
/queens/linkedin-queens-774Live puzzle board miniatureSolve Now
/archive/linkedin-puzzle-archiveArchive-specific headlinePlay Now

→ §5 Dynamic Open Graph images


4. Add JSON-LD structured data

Do: Inject BreadcrumbList, FAQPage, Article, and ItemList schemas via <script type="application/ld+json">. Mirror on-page FAQ text exactly in the schema.

Why: Structured data enables rich results (FAQ accordions, breadcrumbs in SERPs) and helps search engines understand page type.

Example: Archive pages emit BreadcrumbList + ItemList + FAQPage from a single buildLinkedInArchiveStructuredData() call.

→ §6 JSON-LD structured data


5. Create SEO intent landing pages

Do: Build curated landing pages for high-value search queries (e.g. /daily-puzzles, /puzzle-solutions). Each page needs a real H1, intro, FAQ, related links, and optionally filtered content — not just keywords.

Why: Intent pages capture category searches your core routes do not target directly.

Example (Puzzle Games): 10 intent pages including /linkedin-puzzles, /easy-puzzles, and /how-to-play.

→ §7 SEO intent landing pages


6. Generate split sitemaps at build time

Do: Run a build script that writes a sitemap index plus child sitemaps grouped by page type (pages, archives, guides, individual items). Include <lastmod> on content URLs.

Why: A single 500+ URL sitemap is hard to maintain. Split sitemaps help crawlers prioritize and let you update sections independently.

Example: sitemap-pages.xml (21 URLs) + sitemap-puzzles.xml (200+ URLs) + robots.txt referencing the index.

→ §9 Sitemaps and robots.txt


7. Publish llms.txt for AI crawlers

Do: Generate public/llms.txt and markdown mirrors of key pages at build time. Advertise the file in app/layout.tsx via alternates.types.

Why: AI agents and LLM-powered search use llms.txt to discover and summarize your site efficiently.

Example: https://your-domain.com/llms.txt plus per-game how-to-play markdown files.

→ §10 LLM / AI discoverability


8. Notify search engines after deploy (IndexNow + sitemap ping)

Do: After each deploy, submit new or changed URLs via IndexNow and ping Google/Bing with your sitemap URL. Automate this in a script tied to your CI/CD pipeline.

Why: Sitemaps tell crawlers what exists; IndexNow tells them what changed today. Together they speed up indexing of new content.

Example workflow:

npm run build                  # regenerates sitemaps
# deploy
npm run submit:indexnow        # push changed URLs
npm run ping:sitemap           # notify Google/Bing

→ §11 Indexing after deploy


9. Verify ownership in search consoles

Do: Add verification meta tags or HTML files for Google Search Console, Bing Webmaster Tools, and any regional engines you care about (Yandex, Naver, etc.).

Why: Verification unlocks indexing reports, sitemap submission UI, and performance data you cannot get otherwise.

Important: Store verification codes in environment variables — do not publish them in open documentation.

→ §12 Search engine verification


10. Test before and after every deploy

Do: Validate OG images, JSON-LD, and sitemap reachability with automated smoke tests and manual checks.

Quick checklist:

  • opengraph.xyz — image loads, headline matches, CTA visible
  • Google Rich Results Test — FAQ and breadcrumb schemas valid
  • curl -sI https://your-domain.com/sitemap.xml — returns 200
  • curl -sI https://your-domain.com/queens/linkedin-queens-774/opengraph-image — returns PNG 200

→ §15 Testing and debugging


1. Architecture

The goal is one pipeline from route → metadata → crawlers, with no per-page copy-paste.

Page route (app/**/page.tsx)
    │
    ├─ pageMetadata()       → title, description, canonical, keywords, OG, Twitter
    ├─ StructuredData       → JSON-LD schemas
    ├─ Breadcrumbs          → visible nav + BreadcrumbList schema
    └─ FaqSection           → on-page FAQ (mirrored in FAQPage schema)

Build time (before next build)
    ├─ generate-seo.mjs     → public/sitemap*.xml, public/robots.txt
    └─ generate-llms.ts     → public/llms.txt, markdown mirrors

Runtime (Next.js ImageResponse routes)
    └─ app/**/opengraph-image.tsx → 1200×630 PNG share cards

Design principles

PrincipleWhy
One metadata functionEvery page calls the same helper so canonical URLs and social tags never drift
Separate title/description buildersKeeps copy consistent; change a pattern once, update every page of that kind
Path → context → keywordsURL structure drives keyword lists automatically
On-page FAQ = JSON-LD FAQGoogle rewards FAQ rich results; content must match schema
OG images as routesNext.js opengraph-image.tsx files generate PNGs at build/request time — no manual design per page
Split sitemapsLarge sites (hundreds of URLs) benefit from sitemap indexes grouped by page type

Example site structure (Puzzle Games)

URL patternPage kindCustom OG?
/HomeDefault (/opengraph-image)
/queensGame landingYes (/queens/opengraph-image)
/queens/linkedin-queens-774Individual puzzleYes (per-puzzle image)
/archive/linkedin-puzzle-archiveArchive listingYes (archive-specific image)
/daily-puzzlesSEO intent landingDefault
/queens/how-to-playGuideDefault

2. Central metadata helper

Problem: Exporting raw metadata on every page leads to missing canonicals, inconsistent Twitter cards, and forgotten og:image tags.

Solution: A single pageMetadata(path, overrides) function that merges page-specific values with site defaults.

Step 1 — Create the helper (lib/page-metadata.ts)

import type { Metadata } from "next";
import { getMetaKeywordsForPath } from "@/lib/seo-keywords";
import { absoluteUrl } from "@/lib/site-url";

const DEFAULT_OG_IMAGE_PATH = "/opengraph-image";
const OG_IMAGE_DIMENSIONS = { width: 1200, height: 630 };

export function pageMetadata(path: string, metadata: Metadata = {}): Metadata {
  const canonical = path.startsWith("/") ? path : `/${path}`;
  const keywords = getMetaKeywordsForPath(path);
  const ogImage = metadata.openGraph?.images ?? [defaultOgImage()];

  return {
    ...metadata,
    keywords,
    alternates: { ...metadata.alternates, canonical },
    openGraph: {
      siteName: "Your Site Name",
      url: absoluteUrl(canonical),
      type: "website",
      ...metadata.openGraph,
      images: ogImage,
    },
    twitter: {
      card: "summary_large_image",
      ...metadata.twitter,
      images: metadata.twitter?.images ?? ogImage,
    },
  };
}

Step 2 — Set root defaults (app/layout.tsx)

export const metadata: Metadata = {
  metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),
  title: {
    default: "Puzzle Games: Logic & Word Puzzles",
    template: "%s | Puzzle Games",   // "Play Queens Puzzles Online | Puzzle Games"
  },
  description: "Play free daily logic & word puzzles with solutions, levels & random mode.",
  robots: { index: true, follow: true },
};

Step 3 — Use on every page

Minimal page (uses default OG image):

// app/privacy-policy/page.tsx
export const metadata = pageMetadata("/privacy-policy", {
  title: "Privacy Policy",
  description: "How we handle data on Puzzle Games.",
});

Page with custom OG image:

// app/archive/linkedin-puzzle-archive/page.tsx
export const metadata = pageMetadata("/archive/linkedin-puzzle-archive", {
  title: "LinkedIn Daily Puzzle Archive",
  description: "Browse every archived LinkedIn daily puzzle across all games.",
  openGraph: {
    images: [
      routeOgImage("/archive/linkedin-puzzle-archive", "LinkedIn Daily Puzzle Archive"),
    ],
  },
});

OG image URL helpers

Create small helpers so metadata and image routes stay in sync:

// Per-puzzle: /queens/linkedin-queens-774/opengraph-image
export function puzzleOgImage(type: string, slug: string, alt: string) {
  return { url: `/${type}/${slug}/opengraph-image`, ...OG_IMAGE_DIMENSIONS, alt };
}

// Per-game landing: /pinpoint/opengraph-image
export function typeOgImage(type: string, alt: string) {
  return { url: `/${type}/opengraph-image`, ...OG_IMAGE_DIMENSIONS, alt };
}

// Any route: /archive/opengraph-image
export function routeOgImage(path: string, alt: string) {
  return { url: `${path}/opengraph-image`, ...OG_IMAGE_DIMENSIONS, alt };
}

What gets emitted in HTML

For /queens/linkedin-queens-774, the merged metadata produces tags like:

<link rel="canonical" href="https://your-domain.com/queens/linkedin-queens-774" />
<meta property="og:title" content="LinkedIn Queens #774 – Hints, Answer & Solution" />
<meta property="og:description" content="Play LinkedIn Queens 774 online. Replay this LinkedIn daily..." />
<meta property="og:image" content="https://your-domain.com/queens/linkedin-queens-774/opengraph-image" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />

3. Titles and descriptions

Keep copy in dedicated modules — not inline in page files.

Titles (lib/page-titles.ts)

Aim for ~60 characters per title where practical — Google often truncates longer titles in search results, but intent-rich titles slightly over 60 chars are acceptable.

Define one function per page kind, not per page:

export function typeLandingTitle(label: string) {
  return `Play ${label} Puzzles Online`;          // → "Play Queens Puzzles Online"
}

export function puzzlePlayTitle(type: string, id: string, options?: {...}) {
  return `${getPuzzleTitle(type, id, options)} – Hints, Answer & Solution`;
  // → "LinkedIn Crossclimb #781 – Hints, Answer & Solution"
}

export function formatPageTitle(base: string) {
  const withSuffix = `${base} | Puzzle Games`;
  return { absolute: withSuffix.length <= 60 ? withSuffix : base };
  // Short: "Privacy Policy" → "Privacy Policy | Puzzle Games"
  // Long:  "LinkedIn Crossclimb #781 – Hints, Answer & Solution" → no suffix (>60)
}

export function linkedInPuzzleArchiveTitle() {
  return "LinkedIn Daily Puzzle Archive";
}

Wire into generateMetadata or static metadata exports via pageMetadata(). String titles are passed through formatPageTitle() automatically — | Puzzle Games is appended only when the full title stays within ~60 characters:

// app/[type]/page.tsx
return pageMetadata(`/${params.type}`, {
  title: typeLandingTitle(meta.label),
  description: typeLandingDescription(meta),
});

// app/[type]/[id]/page.tsx
return pageMetadata(`/${params.type}/${params.id}`, {
  title: puzzlePlayTitle(params.type, puzzle.id, catalog),
  description: puzzlePlayDescription(params.type, puzzle.id, catalog),
});

Descriptions (lib/page-descriptions.ts)

Social platforms truncate around 125–155 characters. Cap descriptions explicitly:

export const OG_DESCRIPTION_MAX = 125;

export function truncateOgDescription(text: string): string {
  if (text.length <= OG_DESCRIPTION_MAX) return text;
  const cut = text.slice(0, OG_DESCRIPTION_MAX - 1).trimEnd();
  const lastSpace = cut.lastIndexOf(" ");
  const base = lastSpace > 100 ? cut.slice(0, lastSpace) : cut;
  return `${base}…`;
}

export function typeLandingDescription(meta: PuzzleTypeMeta): string {
  return truncateOgDescription(
    `Play ${meta.label} free online. ${meta.description}`,
  );
  // Queens example (~95 chars):
  // "Play Queens free online. Place one queen per row, column, and region without touching."
}

Context-aware puzzle descriptions

Same route pattern, different copy based on content source:

export function puzzlePlayDescription(type, id, options) {
  if (options?.kind === "level") {
    return truncateOgDescription(
      `Play ${title} online. Climb the ${label} level track with hints and a full step-by-step solution.`,
    );
  }
  if (options?.source === "linkedin") {
    return truncateOgDescription(
      `Play ${title} online. Replay this LinkedIn daily with hints and a full step-by-step solution.`,
    );
  }
  // ...
}

4. Path-aware keywords

Meta keywords have limited ranking impact, but they help internal consistency and JSON-LD enrichment. The pattern: infer page kind from URL → return a curated keyword list.

Step 1 — Define page kinds

// lib/seo-keywords.ts
export type SeoPageKind =
  | "home"
  | "archive"
  | "linkedin-puzzle"
  | "how-to-play"
  | "intent-landing"
  // ...

export function resolveSeoContext(path: string): SeoContext {
  if (path === "/") return { kind: "home" };
  if (path === "/archive/linkedin-puzzle-archive") return { kind: "linkedin-archive" };
  if (path.startsWith("/queens/linkedin-")) return { kind: "linkedin-puzzle", gameLabel: "Queens" };
  // ...
}

Step 2 — Map kinds to keyword lists

function keywordsForContext(context: SeoContext): string[] {
  switch (context.kind) {
    case "linkedin-puzzle":
      return [
        "LinkedIn puzzle",
        "LinkedIn Queens puzzle answer",
        "LinkedIn Queens solution",
        `today Queens LinkedIn answer`,
        // ...
      ];
    case "how-to-play":
      return [
        `how to play ${context.gameLabel}`,
        `LinkedIn ${context.gameLabel} rules`,
        // ...
      ];
  }
}

Step 3 — Expand variants

For case-insensitive queries, duplicate with casing variants:

function expandVariants(keywords: string[]): string[] {
  // "LinkedIn puzzle" → also "linkedin puzzle"
}

Step 4 — Enrich JSON-LD only (not HTML meta)

export function enrichSeoDescription(description: string, context: SeoContext): string {
  const keywords = getMetaKeywords(context).slice(0, 8).join(", ");
  return `${description} Keywords: ${keywords}.`;
}

Use enrichSeoDescription in buildArticleSchema() and similar — never in the visible <meta name="description">.


5. Dynamic Open Graph images

Static og:image files do not scale when you have hundreds of pages. Next.js supports opengraph-image.tsx routes that render PNGs with @vercel/og (Satori).

Image tiers (recommended pattern)

TierWhen to useCTA example
Site defaultHome, legal, generic pagesPlay Now
SectionArchive, category, hub pagesPlay Now or Browse Archive
ListingGame/product landing pagesSolve Now / Shop Now
DetailIndividual content itemsSolve Now / Read Now

Puzzle Games implements all four:

/opengraph-image                                    → site default
/archive/linkedin-puzzle-archive/opengraph-image    → section
/queens/opengraph-image                             → game landing
/queens/linkedin-queens-774/opengraph-image         → individual puzzle

Step 1 — Shared site card (lib/og-site-image.tsx)

export const OG_SITE_CTA = "Play Now";

export function OgSitePageImage({ headline, subtitle, cta = OG_SITE_CTA }) {
  return (
    <div style={{ display: "flex", width: "100%", height: "100%", ... }}>
      <OgPuzzleIcon size={160} />
      <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
        <div style={{ fontSize: 64, fontWeight: 700, color: "#fff" }}>{headline}</div>
        <div style={{ fontSize: 32, color: "rgba(255,255,255,0.88)" }}>{subtitle}</div>
        <div style={{
          display: "flex", padding: "16px 36px", borderRadius: 999,
          background: "#f97316", fontSize: 30, fontWeight: 700, color: "#fff",
        }}>
          {cta}
        </div>
      </div>
    </div>
  );
}

Step 2 — Site default route (app/opengraph-image.tsx)

import { ImageResponse } from "next/og";
import { OgSitePageImage } from "@/lib/og-site-image";

export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default function OpenGraphImage() {
  return new ImageResponse(
    <OgSitePageImage
      headline="Puzzle Games"
      subtitle="Logic &amp; word puzzles with solutions, levels &amp; random mode"
    />,
    { ...size },
  );
}

Step 3 — Section-specific route

// app/archive/linkedin-puzzle-archive/opengraph-image.tsx
export default function LinkedInArchiveOpenGraphImage() {
  return new ImageResponse(
    <OgSitePageImage
      headline="LinkedIn Daily Puzzle Archive"
      subtitle="Browse every archived LinkedIn daily puzzle across all games."
    />,
    { ...size },
  );
}

Wire metadata on the page (see §2).

Step 4 — Detail pages with live content preview

For individual puzzles, render a miniature of the actual board:

// app/[type]/[id]/opengraph-image.tsx
export function generateStaticParams() {
  return getAllPuzzleParams();  // pre-render all puzzle OG images at build time
}

export default function PuzzleOpenGraphImage({ params }) {
  const puzzle = getPuzzleFromRoute(params.type, params.id);
  return new ImageResponse(
    <OgPuzzleImage type={params.type} puzzle={puzzle} />,
    { width: 1200, height: 630 },
  );
}

The card layout (lib/og-puzzle-image.tsx) includes:

  • Per-game gradient background
  • Headline (e.g. Queens 774)
  • Subtitle (e.g. 9x9 - Hard)
  • White Solve Now CTA pill
  • Miniature board preview on the right

Step 5 — Wire metadata on the puzzle page

// app/[type]/[id]/page.tsx
export function generateMetadata({ params }) {
  const puzzle = getPuzzleFromRoute(params.type, params.id);
  const ogAlt = getOgPuzzleAlt(params.type, puzzle);
  // alt example: "Queens 774 — 9x9 - Hard"

  return pageMetadata(`/${params.type}/${params.id}`, {
    title: puzzlePlayTitle(params.type, puzzle.id, catalog),
    description: puzzlePlayDescription(params.type, puzzle.id, catalog),
    openGraph: {
      images: [puzzleOgImage(params.type, params.id, ogAlt)],
    },
  });
}

Content helpers for OG text

Keep headline/subtitle logic separate from rendering:

// lib/og-puzzle-content.ts
export function getOgPuzzleHeadline(type, puzzle) {
  return getPuzzleTitle(type, puzzle.id).replace(/^LinkedIn /, "");
}

export function getOgPuzzleSubtitle(type, puzzle) {
  switch (type) {
    case "pinpoint":
      return `${difficulty} - ${clueCount} clues - 1 category`;
    case "queens":
      return `${size}x${size} - ${difficulty}`;
    // ...
  }
}

Satori rendering rules (learned from production)

These constraints apply to all @vercel/og / Satori projects:

RuleReason
import React from "react" in every OG fileSatori requires React in scope
display: "flex" on every containerSatori does not support block layout the same way browsers do
No boxShadowUnsupported — use borders or backgrounds instead
Avoid Unicode in text: ×, •, →Default font lacks glyphs — convert to ASCII (x, -, ->)
Do not use generateImageMetadata without idCaused HTTP 500 in production; set alt in page metadata instead
OG CTAs: no arrow charactersTools like opengraph.xyz detect Play Now, Solve Now better than Play Now →

Example ASCII sanitizer:

export function toOgAsciiText(text: string): string {
  return text
    .replace(/\u00d7/g, "x")    // × → x
    .replace(/\u2022/g, "|")    // • → |
    .replace(/\u2192/g, "->");  // → → ->
}

CTA best practices

  • Use recognizable action phrases: Play Now, Solve Now, Read More, Get Started
  • Render CTAs as high-contrast pill buttons (not plain text)
  • Keep site UI arrows (→) separate from OG image text — social preview tools analyze the image, not your React buttons

6. JSON-LD structured data

Structured data helps search engines understand page type and enables rich results (FAQ accordions, breadcrumbs in SERPs).

Inject schemas (components/StructuredData.tsx)

export function StructuredData({ data }: { data: JsonLd | JsonLd[] }) {
  const schemas = Array.isArray(data) ? data : [data];
  return (
    <>
      {schemas.map((schema, i) => (
        <script
          key={i}
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
        />
      ))}
    </>
  );
}

Schema builders (lib/structured-data.ts)

BuilderSchema typeExample page
buildBreadcrumbSchema()BreadcrumbListAll pages
buildFaqSchema()FAQPageHome, archives, intent pages
buildArticleSchema()ArticleIndividual puzzles
buildItemListSchema()ItemListArchive listings
buildHowToPlayStructuredData()HowTo/queens/how-to-play

Example: archive page with three schemas

// app/archive/linkedin-puzzle-archive/page.tsx
export default function LinkedInPuzzleArchivePage() {
  const puzzles = getAllLinkedInSummaries();

  return (
    <div>
      <StructuredData data={buildLinkedInArchiveStructuredData(puzzles)} />
      {/* ↑ emits BreadcrumbList + ItemList + FAQPage */}

      <PuzzleBreadcrumbs items={linkedInPuzzleArchiveCrumbs()} />
      <h1>LinkedIn Puzzle Archive</h1>
      <PuzzleArchiveList puzzles={puzzles} />
      <FaqSection items={LINKEDIN_ARCHIVE_FAQ} />
    </div>
  );
}

FAQ pattern (critical for rich results)

Rule: FAQ text in JSON-LD must match visible on-page FAQ exactly.

// lib/structured-data.ts — single source of truth
export const LINKEDIN_ARCHIVE_FAQ = [
  {
    question: "Can I get LinkedIn puzzle answers here?",
    answer: "Yes. Open any archived LinkedIn daily and switch to the Full solution tab...",
  },
  // ...
];
// Page uses the same array for both:
<StructuredData data={buildFaqSchema(LINKEDIN_ARCHIVE_FAQ)} />
<FaqSection items={LINKEDIN_ARCHIVE_FAQ} />

Example Article schema for a puzzle

buildArticleSchema({
  headline: "LinkedIn Queens #774 – Hints, Answer & Solution",
  description: enrichSeoDescription(puzzleDescription, seoContext),
  url: "https://your-domain.com/queens/linkedin-queens-774",
  datePublished: "2024-06-10",
});

7. SEO intent landing pages

Beyond your core routes, create curated landing pages that target specific search intents. Each page is a real, useful destination — not a keyword-stuffed doorway.

Step 1 — Define configs in one file

// lib/seo-intent-pages.ts
export const SEO_INTENT_PAGES = {
  "daily-puzzles": {
    path: "/daily-puzzles",
    title: "Daily Puzzles — Play Free Online | Puzzle Games",
    description: "Play today's and past daily puzzles for every game...",
    h1: "Daily Puzzles",
    intro: "Play a new daily puzzle for every game...",
    variant: "archive",           // layout variant
    getPuzzles: getDailySummaries,
    faq: [/* ... */],
    relatedLinks: [/* ... */],
  },
  "linkedin-puzzles": { /* ... */ },
  "puzzle-solutions": { /* ... */ },
  // 10 total intent pages on Puzzle Games
};

Step 2 — Factory for thin route files

// lib/create-seo-intent-page.tsx
export function createSeoIntentPage(slug: SeoIntentSlug) {
  const config = getSeoIntentPage(slug);
  const metadata = pageMetadata(config.path, {
    title: config.title,
    description: config.description,
  });
  function Page() {
    return <SeoIntentLandingPage config={config} />;
  }
  return { metadata, Page };
}

Step 3 — One-liner route file

// app/daily-puzzles/page.tsx
const { metadata, Page } = createSeoIntentPage("daily-puzzles");
export { metadata };
export default Page;

Intent pages on Puzzle Games

PathSearch intent targeted
/daily-puzzles"daily puzzles online"
/logic-puzzles"logic puzzles online"
/word-puzzles"word puzzles online"
/linkedin-puzzles"linkedin puzzles" / "linkedin puzzle archive"
/easy-puzzles"easy puzzles"
/medium-puzzles"medium puzzles"
/hard-puzzles"hard puzzles"
/puzzle-solutions"puzzle solutions" / "queens puzzle solver"
/how-to-play"how to play linkedin puzzles"
/best-logic-puzzles"best logic puzzles online"

Each page includes H1, intro, FAQ, related links, and optionally a filtered content list — making it genuinely useful, not just an SEO shell.


8. Breadcrumbs

Breadcrumbs serve users and search engines. Define trails once, use for both visible nav and JSON-LD.

// lib/breadcrumbs.ts
export function puzzleCrumbs(meta: PuzzleTypeMeta, title: string): BreadcrumbItem[] {
  return [
    { label: meta.label, href: `/${meta.type}` },
    { label: title },
  ];
}
// Visible: Home > Queens > LinkedIn Queens 774
<PuzzleBreadcrumbs items={puzzleCrumbs(meta, title)} />
<StructuredData data={buildBreadcrumbSchema(breadcrumbsToSchemaItems(crumbs))} />

9. Sitemaps and robots.txt

For static sites with hundreds of URLs, generate sitemaps at build time rather than at runtime.

Step 1 — Build script (scripts/generate-seo.mjs)

Group URLs by type into separate sitemaps:

const sitemapPages    = ["/", "/daily-puzzles", "/queens", /* ... */];
const sitemapArchives = ["/archive", "/archive/linkedin-puzzle-archive", /* ... */];
const sitemapGuides   = ["/queens/how-to-play", "/queens/levels", /* ... */];
const sitemapPuzzles  = allPuzzles.map(p => `/${p.type}/${p.slug}`);

// Write sitemap.xml (index), sitemap-pages.xml, sitemap-archives.xml, etc.

Step 2 — Sitemap index structure

<!-- public/sitemap.xml -->
<sitemapindex>
  <sitemap><loc>https://your-domain.com/sitemap-pages.xml</loc></sitemap>
  <sitemap><loc>https://your-domain.com/sitemap-archives.xml</loc></sitemap>
  <sitemap><loc>https://your-domain.com/sitemap-guides.xml</loc></sitemap>
  <sitemap><loc>https://your-domain.com/sitemap-puzzles.xml</loc></sitemap>
</sitemapindex>

Individual puzzle URLs include <lastmod> from content date:

<url>
  <loc>https://your-domain.com/queens/linkedin-queens-774</loc>
  <lastmod>2024-06-10</lastmod>
</url>

Step 3 — robots.txt with AI crawler allowances

User-Agent: *
Allow: /

User-Agent: GPTBot
Allow: /

User-Agent: ClaudeBot
Allow: /

User-Agent: PerplexityBot
Allow: /

Sitemap: https://your-domain.com/sitemap.xml

Step 4 — Run before every build

// package.json
{
  "scripts": {
    "generate:seo": "node scripts/generate-seo.mjs",
    "generate:static-seo": "npm run generate:seo && npm run generate:llms",
    "build": "npm run generate:static-seo && next build"
  }
}

Site URL resolution

Use an environment variable so the same script works locally and in production:

function getSiteUrl() {
  if (process.env.NEXT_PUBLIC_SITE_URL) return process.env.NEXT_PUBLIC_SITE_URL;
  if (process.env.VERCEL_ENV === "production") return "https://your-production-domain.com";
  return "http://localhost:3000";
}

Set NEXT_PUBLIC_SITE_URL=https://your-domain.com in your hosting provider's environment variables.


10. LLM / AI discoverability

The llms.txt specification gives AI agents a concise, machine-readable site summary.

What to generate

FilePurpose
public/llms.txtShort summary: what the site is, key links
public/llms-full.txtExpanded version with FAQ and more links
public/{path}.mdMarkdown mirrors of important pages

Advertise in layout

// app/layout.tsx
export const metadata = {
  alternates: {
    types: { "text/plain": "/llms.txt" },
  },
};

Build script

// scripts/generate-llms.ts
const { llmsTxt, llmsFullTxt, markdownPages } = buildLlmsContent();
fs.writeFileSync("public/llms.txt", llmsTxt);
fs.writeFileSync("public/llms-full.txt", llmsFullTxt);
for (const page of markdownPages) {
  fs.writeFileSync(`public/${page.path}.md`, page.content);
}

Content is assembled from the same data that powers the site (game list, FAQ, how-to-play guides) — see lib/llms-content.ts.


11. Indexing after deploy

Publishing sitemaps is not enough — notify search engines when content changes.

IndexNow (Bing, Yandex, Naver, and others)

IndexNow lets you push URL changes to participating search engines instantly — much faster than waiting for crawlers to rediscover pages on their own.

Official docs:

IndexNow and sitemaps work together: use sitemaps for a complete URL inventory, and IndexNow to notify engines when specific pages are added, updated, or deleted.

Step 1 — Generate an API key

Per the IndexNow key FAQ:

  1. Generate a key string 8–128 characters long using a UUID/GUID generator or similar.

  2. Allowed characters: a–z, A–Z, 0–9, and hyphens (-).

  3. Create a UTF-8 text file named {your-key}.txt whose entire contents are the key string (nothing else).

  4. Upload the file to your site root so it is publicly accessible at:

    https://your-domain.com/{your-key}.txt
    
  5. Verify in a browser — the file must load and display only the key as plain text (no login, no HTML wrapper).

Example: If your key is abcd1234-5678-90ef, host public/abcd1234-5678-90ef.txt containing:

abcd1234-5678-90ef

Store the key in an environment variable for your submit script:

INDEXNOW_KEY=abcd1234-5678-90ef

Do not commit the key file to a public repository. Generate a separate key per environment if needed.

Step 2 — Submit URLs

Single URL (GET):

https://api.indexnow.org/indexnow?url={url-encoded-page-url}&key={your-key}

Multiple URLs (POST, up to 10,000 per request):

POST https://api.indexnow.org/indexnow
Content-Type: application/json; charset=utf-8

{
  "host": "your-domain.com",
  "key": "your-key",
  "keyLocation": "https://your-domain.com/your-key.txt",
  "urlList": [
    "https://your-domain.com/page-1",
    "https://your-domain.com/page-2"
  ]
}

Expected HTTP responses:

CodeMeaning
200URL received and processed
202Accepted; key verification pending (common on first submission)
400Bad request — check key format and URL encoding
422Key file missing, domain mismatch, or duplicate unchanged URL
429Rate limited — wait and retry

Step 3 — Automate with a submit script

The reference implementation (scripts/submit-indexnow.mjs) handles incremental submission:

  • Reads all URLs from generated sitemaps in public/
  • Compares against a local manifest (.generated/indexnow-manifest.json)
  • POSTs only new or changed URLs to https://api.indexnow.org/indexnow
  • Supports --full to resubmit everything (use after a migration, not on every deploy)
npm run submit:indexnow           # incremental — submit only changed URLs
npm run submit:indexnow -- --full # full resubmit — use sparingly

When to submit: Notify IndexNow whenever content is added, updated, or removed. Do not resubmit unchanged URLs — wait at least 5 minutes between submissions of the same URL unless content meaningfully changed.

When not to submit: IndexNow is for recent changes. For pages updated before IndexNow was set up, rely on sitemaps with accurate <lastmod> values instead.

Sitemap ping (Google, Bing)

# scripts/ping-sitemap.mjs
curl "https://www.google.com/ping?sitemap=https://your-domain.com/sitemap.xml"
curl "https://www.bing.com/ping?sitemap=https://your-domain.com/sitemap.xml"

Run after deploy:

npm run ping:sitemap

Recommended deploy workflow

1. npm run build                  # regenerates sitemaps + llms.txt
2. deploy to hosting
3. npm run submit:indexnow        # push new URLs
4. npm run ping:sitemap           # notify Google/Bing

12. Search engine verification

Search consoles require proving domain ownership. Common methods:

EngineMethodWhere to configure
Google Search ConsoleDNS TXT record or HTML meta tagapp/layout.tsx → metadata.verification.google
Bing Webmaster ToolsXML file or meta tagmetadata.verification.other or public/BingSiteAuth.xml
YandexMeta tagmetadata.verification.yandex
NaverHTML file in public/public/naver{code}.html

Example (layout metadata):

export const metadata = {
  verification: {
    google: "your-google-verification-code",
    yandex: "your-yandex-verification-code",
  },
};

Important: Verification codes are site-specific secrets. Store them in environment variables or your hosting dashboard — do not publish them in open documentation.


13. Cookbook: add SEO to a new page

Example: adding /leaderboard to Puzzle Games.

1. Create the route

// app/leaderboard/page.tsx
import { pageMetadata } from "@/lib/page-metadata";
import { StructuredData } from "@/components/StructuredData";
import { FaqSection } from "@/components/FaqSection";

export const metadata = pageMetadata("/leaderboard", {
  title: "Puzzle Leaderboard",
  description: "See top scores across every puzzle game.",
});

export default function LeaderboardPage() {
  return (
    <div>
      <StructuredData data={buildLeaderboardStructuredData()} />
      <h1>Puzzle Leaderboard</h1>
      <FaqSection items={LEADERBOARD_FAQ} />
    </div>
  );
}

2. Add title/description helpers

// lib/page-titles.ts
export function leaderboardTitle() { return "Puzzle Leaderboard"; }

// lib/page-descriptions.ts
export function leaderboardDescription() {
  return truncateOgDescription("See top scores across every puzzle game on Puzzle Games.");
}

3. Add keyword context

// lib/seo-keywords.ts — in resolveSeoContext():
if (path === "/leaderboard") return { kind: "leaderboard" };

// in keywordsForContext():
case "leaderboard":
  return ["puzzle leaderboard", "high scores", "puzzle game rankings"];

4. Add breadcrumbs + structured data

// lib/breadcrumbs.ts
export function leaderboardCrumbs() {
  return [{ label: "Leaderboard" }];
}

// lib/structured-data.ts
export function buildLeaderboardStructuredData() {
  return [
    buildBreadcrumbSchema(breadcrumbsToSchemaItems(leaderboardCrumbs())),
    buildFaqSchema(LEADERBOARD_FAQ),
  ];
}

5. (Optional) Custom OG image

// app/leaderboard/opengraph-image.tsx
export default function LeaderboardOgImage() {
  return new ImageResponse(
    <OgSitePageImage headline="Puzzle Leaderboard" subtitle="Top scores across every game." />,
    { width: 1200, height: 630 },
  );
}
// In page metadata:
openGraph: { images: [routeOgImage("/leaderboard", "Puzzle Leaderboard")] },

6. Add to sitemap

// scripts/generate-seo.mjs — in corePagePaths():
const paths = ["/", "/leaderboard", /* ... */];

7. Build and verify

npm run build
npm run submit:indexnow

14. Cookbook: add SEO to a new content type

Example: adding a new game type sudoku to Puzzle Games.

1. Register the type

// lib/puzzle-meta.ts
export const PUZZLE_TYPES = [
  // ...
  { type: "sudoku", label: "Sudoku", description: "Fill the grid with digits 1–9.", supportsRandom: true },
];

This automatically expands: sitemap entries, static params, keyword lists, llms.txt.

2. Add OG board renderer

// lib/og-puzzle-image.tsx
function SudokuBoard({ puzzle }) { /* miniature grid */ }

function OgPuzzleBoard({ type, puzzle }) {
  switch (type) {
    case "sudoku": return <SudokuBoard puzzle={puzzle} />;
    // ...
  }
}

3. Add OG subtitle logic

// lib/og-puzzle-content.ts
case "sudoku":
  return `${puzzle.size}x${puzzle.size} - ${difficulty}`;

4. Add game gradient

// lib/og-puzzle-image.tsx
const GAME_GRADIENT = {
  sudoku: { from: "#3b82f6", to: "#1d4ed8" },
};

5. Create standard routes

app/sudoku/page.tsx              → game landing (uses typeOgImage)
app/sudoku/opengraph-image.tsx   → game OG with sample board
app/sudoku/[id]/page.tsx         → puzzle play (uses puzzleOgImage)
app/sudoku/[id]/opengraph-image.tsx
app/sudoku/how-to-play/page.tsx
app/sudoku/levels/page.tsx

No sitemap changes needed — puzzle URLs are discovered from data/sudoku/*.json automatically.


15. Testing and debugging

Local OG smoke test

npx tsx scripts/test-og-image.ts

Renders sample OG images for each game type and validates subtitle strings.

Manual checks after deploy

Replace your-domain.com with your actual domain:

# Confirm og:image tag in HTML
curl -sL https://your-domain.com/queens/linkedin-queens-774 | grep 'og:image'

# Confirm OG image returns PNG 200
curl -sI https://your-domain.com/queens/linkedin-queens-774/opengraph-image

# Confirm sitemap is reachable
curl -sI https://your-domain.com/sitemap.xml

Online tools

ToolWhat to check
opengraph.xyzSocial preview: image loads, headline matches, CTA visible
Google Rich Results TestJSON-LD validates; FAQ eligible
Google Search ConsoleIndex coverage, sitemap status
Bing Webmaster ToolsIndexNow submission status

Common OG failures

SymptomFix
HTTP 500 on /opengraph-imageAdd import React from "react"; remove unsupported CSS
Blank board in imageAdd display: "flex" to all Satori containers
Garbled × or • in subtitleRun text through toOgAsciiText()
Wrong image when sharingPage metadata missing custom openGraph.images — falls back to site default
opengraph.xyz says "No CTA"Use a high-contrast pill button with Play Now or Solve Now text

16. Reference file map

This is the file layout in the Puzzle Games reference implementation:

app/
  layout.tsx                              Root metadata, robots, llms.txt alternate
  opengraph-image.tsx                     Site default OG
  archive/opengraph-image.tsx             Section OG
  archive/linkedin-puzzle-archive/
    page.tsx                              Metadata + routeOgImage wiring
    opengraph-image.tsx                   Section-specific OG
  [type]/page.tsx                         Game landing metadata
  [type]/opengraph-image.tsx              Game landing OG
  [type]/[id]/page.tsx                    Puzzle metadata + puzzleOgImage
  [type]/[id]/opengraph-image.tsx         Per-puzzle OG
  daily-puzzles/page.tsx                  SEO intent page (one-liner)

lib/
  page-metadata.ts                        Central metadata merger
  page-titles.ts                          Title builders
  page-descriptions.ts                    Description builders + truncation
  seo-keywords.ts                         Path → keywords
  seo-intent-pages.ts                     Intent landing configs
  create-seo-intent-page.tsx              Intent page factory
  structured-data.ts                      JSON-LD builders + FAQ content
  breadcrumbs.ts                          Breadcrumb definitions
  llms-content.ts                         llms.txt content
  og-site-image.tsx                       Site/section OG layout
  og-puzzle-image.tsx                     Detail OG layout + board renderers
  og-puzzle-content.ts                    OG headline/subtitle helpers
  og-type-content.ts                      Landing page OG text
  og-palette.ts                           Satori-safe colors + ASCII helper

scripts/
  generate-seo.mjs                        Sitemaps + robots.txt
  generate-llms.ts                        llms.txt + markdown mirrors
  submit-indexnow.mjs                     IndexNow submission
  ping-sitemap.mjs                        Google/Bing sitemap ping
  test-og-image.ts                        OG render smoke test

public/                                   Generated at build time
  sitemap.xml, sitemap-*.xml
  robots.txt
  llms.txt, llms-full.txt

Publishing this guide

This guide is available in two formats:

The source lives at public/seo-guide.md. Edit that file, then rebuild or redeploy to update both URLs.


Further reading


Reference implementation: Puzzle Games — a static Next.js puzzle archive. Last updated: June 2026.