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.txtfor AI agent discoverability
Table of contents
- Best things to do
- Architecture
- Central metadata helper
- Titles and descriptions
- Path-aware keywords
- Dynamic Open Graph images
- JSON-LD structured data
- SEO intent landing pages
- Breadcrumbs
- Sitemaps and robots.txt
- LLM / AI discoverability
- Indexing after deploy
- Search engine verification
- Cookbook: add SEO to a new page
- Cookbook: add SEO to a new content type
- Testing and debugging
- 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. 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. 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
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.
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.
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.
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
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 200curl -sI https://your-domain.com/queens/linkedin-queens-774/opengraph-image— returns PNG 200
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)
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)
| 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)
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 & word puzzles with solutions, levels & 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:
| 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:
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)
| 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
// 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
| 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.
// 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
| 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
// 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:
-
Generate a key string 8–128 characters long using a UUID/GUID generator or similar.
-
Allowed characters:
a–z,A–Z,0–9, and hyphens (-). -
Create a UTF-8 text file named
{your-key}.txtwhose entire contents are the key string (nothing else). -
Upload the file to your site root so it is publicly accessible at:
https://your-domain.com/{your-key}.txt -
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:
| 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
--fullto 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:
| 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):
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
| Tool | What to check |
|---|---|
| opengraph.xyz | Social preview: image loads, headline matches, CTA visible |
| Google Rich Results Test | 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
- Raw Markdown: 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
- Next.js OG Image Generation
- Schema.org — JSON-LD types
- llms.txt specification
- IndexNow protocol
- IndexNow FAQ — API Key and Verification
- Open Graph protocol
Reference implementation: Puzzle Games — a static Next.js puzzle archive. Last updated: June 2026.