# 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](https://puzzlegames.fun) — 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

0. [Best things to do](#best-things-to-do)
1. [Architecture](#1-architecture)
2. [Central metadata helper](#2-central-metadata-helper)
3. [Titles and descriptions](#3-titles-and-descriptions)
4. [Path-aware keywords](#4-path-aware-keywords)
5. [Dynamic Open Graph images](#5-dynamic-open-graph-images)
6. [JSON-LD structured data](#6-json-ld-structured-data)
7. [SEO intent landing pages](#7-seo-intent-landing-pages)
8. [Breadcrumbs](#8-breadcrumbs)
9. [Sitemaps and robots.txt](#9-sitemaps-and-robotstxt)
10. [LLM / AI discoverability](#10-llm--ai-discoverability)
11. [Indexing after deploy](#11-indexing-after-deploy)
12. [Search engine verification](#12-search-engine-verification)
13. [Cookbook: add SEO to a new page](#13-cookbook-add-seo-to-a-new-page)
14. [Cookbook: add SEO to a new content type](#14-cookbook-add-seo-to-a-new-content-type)
15. [Testing and debugging](#15-testing-and-debugging)
16. [Reference file map](#16-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-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-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):**

| URL | OG image | CTA |
|-----|----------|-----|
| `/` | Site default | Play Now |
| `/queens` | Game landing + board preview | Solve Now |
| `/queens/linkedin-queens-774` | Live puzzle board miniature | Solve Now |
| `/archive/linkedin-puzzle-archive` | Archive-specific headline | Play Now |

→ [§5 Dynamic Open Graph images](#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](#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](#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](#9-sitemaps-and-robotstxt)

---

### 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](#10-llm--ai-discoverability)

---

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

**Do:** After each deploy, submit new or changed URLs via [IndexNow](https://www.indexnow.org/) 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:**

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

→ [§11 Indexing after deploy](#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](#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](https://www.opengraph.xyz/) — image loads, headline matches, CTA visible
- [Google Rich Results Test](https://search.google.com/test/rich-results) — 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](#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

| Principle | Why |
|-----------|-----|
| **One metadata function** | Every page calls the same helper so canonical URLs and social tags never drift |
| **Separate title/description builders** | Keeps copy consistent; change a pattern once, update every page of that kind |
| **Path → context → keywords** | URL structure drives keyword lists automatically |
| **On-page FAQ = JSON-LD FAQ** | Google rewards FAQ rich results; content must match schema |
| **OG images as routes** | Next.js `opengraph-image.tsx` files generate PNGs at build/request time — no manual design per page |
| **Split sitemaps** | Large sites (hundreds of URLs) benefit from sitemap indexes grouped by page type |

### Example site structure (Puzzle Games)

| URL pattern | Page kind | Custom OG? |
|-------------|-----------|------------|
| `/` | Home | Default (`/opengraph-image`) |
| `/queens` | Game landing | Yes (`/queens/opengraph-image`) |
| `/queens/linkedin-queens-774` | Individual puzzle | Yes (per-puzzle image) |
| `/archive/linkedin-puzzle-archive` | Archive listing | Yes (archive-specific image) |
| `/daily-puzzles` | SEO intent landing | Default |
| `/queens/how-to-play` | Guide | Default |

---

## 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`)

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

```ts
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):

```ts
// 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**:

```ts
// 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:

```ts
// 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:

```html
<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:

```ts
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:

```ts
// 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:

```ts
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:

```ts
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

```ts
// 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

```ts
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:

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

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

```ts
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)

| Tier | When to use | CTA example |
|------|-------------|-------------|
| **Site default** | Home, legal, generic pages | `Play Now` |
| **Section** | Archive, category, hub pages | `Play Now` or `Browse Archive` |
| **Listing** | Game/product landing pages | `Solve Now` / `Shop Now` |
| **Detail** | Individual content items | `Solve 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`)

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

```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

```tsx
// 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](#2-central-metadata-helper)).

### Step 4 — Detail pages with live content preview

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

```tsx
// 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

```tsx
// 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:

```ts
// 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:

| Rule | Reason |
|------|--------|
| `import React from "react"` in every OG file | Satori requires React in scope |
| `display: "flex"` on every container | Satori does not support block layout the same way browsers do |
| No `boxShadow` | Unsupported — use borders or backgrounds instead |
| Avoid Unicode in text: `×`, `•`, `→` | Default font lacks glyphs — convert to ASCII (`x`, `-`, `->`) |
| Do not use `generateImageMetadata` without `id` | Caused HTTP 500 in production; set `alt` in page metadata instead |
| OG CTAs: no arrow characters | Tools like opengraph.xyz detect `Play Now`, `Solve Now` better than `Play Now →` |

Example ASCII sanitizer:

```ts
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`)

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

| Builder | Schema type | Example page |
|---------|-------------|--------------|
| `buildBreadcrumbSchema()` | `BreadcrumbList` | All pages |
| `buildFaqSchema()` | `FAQPage` | Home, archives, intent pages |
| `buildArticleSchema()` | `Article` | Individual puzzles |
| `buildItemListSchema()` | `ItemList` | Archive listings |
| `buildHowToPlayStructuredData()` | `HowTo` | `/queens/how-to-play` |

### Example: archive page with three schemas

```tsx
// 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.

```ts
// 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...",
  },
  // ...
];
```

```tsx
// Page uses the same array for both:
<StructuredData data={buildFaqSchema(LINKEDIN_ARCHIVE_FAQ)} />
<FaqSection items={LINKEDIN_ARCHIVE_FAQ} />
```

### Example Article schema for a puzzle

```ts
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

```ts
// 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

```ts
// 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

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

### Intent pages on Puzzle Games

| Path | Search 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.

```ts
// 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
```

```tsx
<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:

```js
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

```xml
<!-- 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:

```xml
<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

```txt
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

```json
// 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:

```js
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](https://llmstxt.org/) gives AI agents a concise, machine-readable site summary.

### What to generate

| File | Purpose |
|------|---------|
| `public/llms.txt` | Short summary: what the site is, key links |
| `public/llms-full.txt` | Expanded version with FAQ and more links |
| `public/{path}.md` | Markdown mirrors of important pages |

### Advertise in layout

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

### Build script

```ts
// 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](https://www.indexnow.org/) 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 FAQ — API Key and Verification](https://www.indexnow.org/faq#api-key-and-verification)
- [IndexNow protocol overview](https://www.indexnow.org/)

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](https://www.indexnow.org/faq#api-key-and-verification):

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:

```bash
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):**

```json
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:**

| Code | Meaning |
|------|---------|
| `200` | URL received and processed |
| `202` | Accepted; key verification pending (common on first submission) |
| `400` | Bad request — check key format and URL encoding |
| `422` | Key file missing, domain mismatch, or duplicate unchanged URL |
| `429` | Rate 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)

```bash
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)

```bash
# 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:

```bash
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:

| Engine | Method | Where to configure |
|--------|--------|-------------------|
| Google Search Console | DNS TXT record or HTML meta tag | `app/layout.tsx` → `metadata.verification.google` |
| Bing Webmaster Tools | XML file or meta tag | `metadata.verification.other` or `public/BingSiteAuth.xml` |
| Yandex | Meta tag | `metadata.verification.yandex` |
| Naver | HTML file in `public/` | `public/naver{code}.html` |

**Example (layout metadata):**

```ts
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

```tsx
// 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

```ts
// 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

```ts
// 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

```ts
// 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

```tsx
// 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 },
  );
}
```

```ts
// In page metadata:
openGraph: { images: [routeOgImage("/leaderboard", "Puzzle Leaderboard")] },
```

### 6. Add to sitemap

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

### 7. Build and verify

```bash
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

```ts
// 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

```tsx
// 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

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

### 4. Add game gradient

```ts
// 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

```bash
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:

```bash
# 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

| Tool | What to check |
|------|---------------|
| [opengraph.xyz](https://www.opengraph.xyz/) | Social preview: image loads, headline matches, CTA visible |
| [Google Rich Results Test](https://search.google.com/test/rich-results) | JSON-LD validates; FAQ eligible |
| Google Search Console | Index coverage, sitemap status |
| Bing Webmaster Tools | IndexNow submission status |

### Common OG failures

| Symptom | Fix |
|---------|-----|
| HTTP 500 on `/opengraph-image` | Add `import React from "react"`; remove unsupported CSS |
| Blank board in image | Add `display: "flex"` to all Satori containers |
| Garbled `×` or `•` in subtitle | Run text through `toOgAsciiText()` |
| Wrong image when sharing | Page 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:

- **Rendered page:** [https://puzzlegames.fun/seo-guide](https://puzzlegames.fun/seo-guide)
- **Raw Markdown:** [https://puzzlegames.fun/seo-guide.md](https://puzzlegames.fun/seo-guide.md)

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

---

## Further reading

- [Next.js Metadata API](https://nextjs.org/docs/app/building-your-application/optimizing/metadata)
- [Next.js OG Image Generation](https://nextjs.org/docs/app/api-reference/file-conventions/metadata/opengraph-image)
- [Schema.org](https://schema.org/) — JSON-LD types
- [llms.txt specification](https://llmstxt.org/)
- [IndexNow protocol](https://www.indexnow.org/)
- [IndexNow FAQ — API Key and Verification](https://www.indexnow.org/faq#api-key-and-verification)
- [Open Graph protocol](https://ogp.me/)

---

*Reference implementation: [Puzzle Games](https://puzzlegames.fun) — a static Next.js puzzle archive. Last updated: June 2026.*
