# SEO

Two things make or break a launch: your link preview and your search ranking.
This skill is the launch-day setup for both.

## When to use this
- The user is about to launch or share their site.
- The user pasted their link in Slack/Twitter/Discord and got an ugly text-only preview.
- The user wants their site to show up in Google.
- The user is adding any new public page.

## Stack-aware

This skill works for two SEO scopes:
- **Stack A (per-page SEO):** Hand-write metadata for each page, generate one OG image per route. Best for marketing sites and small content sites with <50 pages.
- **Stack B (programmatic SEO):** Auto-generate hundreds of landing pages from a data array (`/best/[category]/[skinType]`, `/compare/[a]/[b]`). Best when you have a database of items and want to capture long-tail search. See "Programmatic SEO" below.

Both apply the same meta tag fundamentals. Stack B just runs them through `generateMetadata` and `generateStaticParams`.

## The full setup (Next.js App Router)

### Step 1 — Site-wide metadata
In `src/app/layout.tsx`:

```tsx
import type { Metadata } from "next";

export const metadata: Metadata = {
  metadataBase: new URL("https://yoursite.com"),
  title: {
    default: "Site Name — Your Tagline",
    template: "%s | Site Name",
  },
  description: "One sentence describing what the site does, who it's for, and why it matters. Keep it under 160 characters.",
  keywords: ["primary", "keywords", "if relevant"],
  openGraph: {
    type: "website",
    locale: "en_US",
    url: "https://yoursite.com",
    siteName: "Site Name",
    title: "Site Name — Your Tagline",
    description: "Same as the description above.",
    images: [
      {
        url: "/og-image.png",
        width: 1200,
        height: 630,
        alt: "Site Name preview",
      },
    ],
  },
  twitter: {
    card: "summary_large_image",
    title: "Site Name — Your Tagline",
    description: "Same as above.",
    images: ["/og-image.png"],
    creator: "@yourhandle",
  },
  robots: { index: true, follow: true },
  alternates: { canonical: "https://yoursite.com" },
};
```

### Step 2 — Per-page metadata
On every page, override what's specific:

```tsx
// app/pricing/page.tsx
export const metadata: Metadata = {
  title: "Pricing",  // becomes "Pricing | Site Name" via the template
  description: "Page-specific description, under 160 characters.",
  alternates: { canonical: "https://yoursite.com/pricing" },
};
```

For dynamic routes, use `generateMetadata`:

```tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const post = await getPost((await params).slug);
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}
```

### Step 3 — Generate Open Graph images
**Static OG image:** drop a 1200×630 PNG at `public/og-image.png` and reference it.

**Dynamic OG images** (one per page, automatically generated):
```tsx
// app/opengraph-image.tsx
import { ImageResponse } from "next/og";

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

export default function Image() {
  return new ImageResponse(
    (
      <div
        style={{
          background: "linear-gradient(135deg, #9772F3, #FB5C8D)",
          width: "100%",
          height: "100%",
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          color: "white",
          fontSize: 96,
          fontWeight: 900,
        }}
      >
        Site Name
      </div>
    ),
    { ...size }
  );
}
```

Drop the file inside any route's folder to get a custom OG image for that route. Drop it next to dynamic `page.tsx` and use `params` to customize per item.

### Step 4 — Sitemap
```tsx
// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = "https://yoursite.com";

  // Static pages
  const staticRoutes = ["", "/pricing", "/about", "/blog"].map((path) => ({
    url: `${baseUrl}${path}`,
    lastModified: new Date(),
    changeFrequency: "weekly" as const,
    priority: path === "" ? 1 : 0.8,
  }));

  // Dynamic pages — fetch from your DB
  const posts = await db.select().from(blogPosts);
  const postRoutes = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "monthly" as const,
    priority: 0.5,
  }));

  return [...staticRoutes, ...postRoutes];
}
```

### Step 5 — robots.txt
```tsx
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: "*", allow: "/", disallow: ["/api/", "/admin/"] },
    ],
    sitemap: "https://yoursite.com/sitemap.xml",
  };
}
```

### Step 6 — Structured data (JSON-LD)
For articles, products, FAQs — adds rich results to Google search:

```tsx
// In a blog post page
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{
    __html: JSON.stringify({
      "@context": "https://schema.org",
      "@type": "Article",
      headline: post.title,
      image: post.coverImage,
      datePublished: post.publishedAt,
      author: { "@type": "Person", name: post.author },
    }),
  }}
/>
```

For an FAQ page:
```json
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "What is X?",
      "acceptedAnswer": { "@type": "Answer", "text": "X is Y." }
    }
  ]
}
```

For a product page with reviews — **AggregateRating** gets you star ratings
in Google search results, which dramatically increases click-through:
```json
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Product Name",
  "image": "https://yoursite.com/product.jpg",
  "description": "Product description",
  "brand": { "@type": "Brand", "name": "Brand Name" },
  "aggregateRating": {
    "@type": "AggregateRating",
    "ratingValue": "4.6",
    "reviewCount": "127",
    "bestRating": "5",
    "worstRating": "1"
  }
}
```

For listing pages and detail pages — **BreadcrumbList** shows the breadcrumb
trail in Google search results, replacing the URL with a friendly path:
```json
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    { "@type": "ListItem", "position": 1, "name": "Home", "item": "https://yoursite.com" },
    { "@type": "ListItem", "position": 2, "name": "Products", "item": "https://yoursite.com/products" },
    { "@type": "ListItem", "position": 3, "name": "Lipstick", "item": "https://yoursite.com/products/lipstick" }
  ]
}
```

Add BreadcrumbList to **every detail page** — it's a free CTR boost.

### Step 7 — Verify with the social debuggers
After deploying:
- **Facebook / Meta:** developers.facebook.com/tools/debug/
- **LinkedIn:** linkedin.com/post-inspector/
- **Slack:** paste the URL in a Slack message and check the preview
- **X/Twitter:** post a test tweet (the validator was deprecated)

If any preview doesn't show, click "Re-scrape" or "Refresh". Social platforms cache previews for 24+ hours.

### Step 8 — Submit to Google
- Go to **search.google.com/search-console**
- Add your domain
- Verify ownership (DNS or HTML file)
- Submit your sitemap URL: `https://yoursite.com/sitemap.xml`

Google will index your site within hours to days.

## Title and description writing rules

**Titles:**
- 50-60 characters max
- Page name first, brand last: `Pricing | Site Name`
- Include the most important keyword once
- No keyword stuffing

**Descriptions:**
- 150-160 characters max
- Lead with the value proposition
- Include the keyword once, naturally
- End with a soft CTA ("Try it free")

## OG image best practices

- **1200 × 630px** is the universal size
- **PNG or JPG**, under 1MB
- **Big, bold text** — readable at thumbnail size
- **High contrast** — looks good on white AND dark backgrounds
- **Brand colors** — instantly recognizable
- **Logo in the corner** — small, not dominant
- **Consistent template** across all pages

## Programmatic SEO [Stack B]

The single highest-leverage SEO move for any data-heavy site: auto-generate
hundreds of landing pages from a data source, each targeting a specific
long-tail keyword. Examples from the wild:

- **chokchok**: `/best/[category]/[skinType]` generates 48 pages targeting
  searches like "best moisturizer for oily skin"
- **chokchok**: `/tools/compare/[a]/[b]` generates 161 product comparison
  pages targeting "Brand X vs Brand Y"
- **codebooks.ai**: 79 chapter pages, each at `/[slug]` with its own metadata
- **Marketplaces**: `/used/[city]/[item]` for every city × every item

### The pattern (Next.js App Router)

```tsx
// app/best/[category]/[skinType]/page.tsx
import type { Metadata } from "next";
import { categories, skinTypes, getProductsFor } from "@/lib/products";

type Props = { params: Promise<{ category: string; skinType: string }> };

// 1. Tell Next.js every combination at build time
export function generateStaticParams() {
  return categories.flatMap((category) =>
    skinTypes.map((skinType) => ({ category, skinType })),
  );
}

// 2. Generate unique metadata per page
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { category, skinType } = await params;
  const title = `Best ${category} for ${skinType} skin`;
  const description = `Hand-picked ${category} for ${skinType} skin, ranked by user reviews and ingredient analysis. Updated weekly.`;
  return {
    title,
    description,
    alternates: {
      canonical: `https://yoursite.com/best/${category}/${skinType}`,
    },
    openGraph: { title, description },
  };
}

// 3. Render the actual page
export default async function Page({ params }: Props) {
  const { category, skinType } = await params;
  const products = await getProductsFor(category, skinType);

  return (
    <div>
      <h1>Best {category} for {skinType} skin</h1>
      <p>Our top picks, ranked by reviews and ingredient analysis.</p>
      {products.map((p) => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}
```

That's it. `generateStaticParams` tells Next.js to pre-render every
combination at build time. `generateMetadata` makes each page's title and
description unique. The result: hundreds of indexable, SEO-optimized pages
from one file.

### Make programmatic SEO actually rank

Generating pages is the easy part. Making them rank requires:

1. **Real, useful content per page** — not just a different title and the
   same product list. Add a 2-3 sentence intro that's genuinely different
   per combination. Add an "Why this combination?" section.
2. **Internal linking** — link each programmatic page to related ones
   ("See also: Best moisturizer for dry skin", "Compare to: Best for combo").
3. **Submit them all to Google Search Console** — add the sitemap that
   includes them, watch the indexing report.
4. **Hreflang if multilingual** — add `<link rel="alternate" hreflang="...">`
   in your generateMetadata.
5. **Don't go overboard** — 100 great pages beat 10,000 thin ones. Google
   penalizes "doorway pages" with no real content.

### The sitemap for programmatic pages

Pull from the same data source:

```tsx
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { categories, skinTypes } from "@/lib/products";

export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = "https://yoursite.com";

  // Static routes
  const staticRoutes = ["", "/products", "/about"].map((path) => ({
    url: `${baseUrl}${path}`,
    lastModified: new Date(),
    priority: path === "" ? 1 : 0.8,
  }));

  // Programmatic /best/[category]/[skinType] routes
  const bestRoutes = categories.flatMap((category) =>
    skinTypes.map((skinType) => ({
      url: `${baseUrl}/best/${category}/${skinType}`,
      lastModified: new Date(),
      changeFrequency: "weekly" as const,
      priority: 0.7,
    })),
  );

  return [...staticRoutes, ...bestRoutes];
}
```

## SEO basics that actually matter

In rough order of impact:

1. **Page speed** — slow sites get demoted. Optimize images, use `next/image`, minimize JS.
2. **Mobile-friendly** — Google indexes mobile-first.
3. **HTTPS** — Vercel/Netlify give you this for free.
4. **Unique titles and descriptions** — no two pages should have the same.
5. **Semantic HTML** — `<h1>`, `<article>`, `<nav>`, `<main>` matter.
6. **Internal linking** — link between your pages with descriptive anchor text.
7. **Canonical URLs** — prevent duplicate content issues.
8. **Sitemap submitted to Search Console** — helps Google find new pages fast.
9. **Structured data** — gets you rich results.
10. **Backlinks** — the hardest part. Worth doing real outreach.

## What good looks like

- Pasting your URL in Slack shows a beautiful preview card
- Google search for your brand returns your site as the first result
- Search Console shows your sitemap was crawled within days
- Every page has a unique `<title>` in the browser tab
- Right-clicking → View Source shows clean meta tags in the `<head>`
- Lighthouse SEO score is 100

## Common mistakes to avoid

- **Same title on every page.** Each page needs its own.
- **Generic OG image.** A logo on white is forgettable. Make it bold and unique per page if possible.
- **Forgetting `metadataBase`.** Without it, OG image URLs are relative and break in social previews.
- **Not re-scraping after changes.** Social platforms cache. Always re-scrape after meta updates.
- **Indexing staging sites.** Add `robots: noindex` to staging URLs.
- **Keyword stuffing.** Google has been past this since 2010. Write for humans.
- **Hidden text or cloaking.** Will get you penalized.
- **Skipping the canonical URL.** Causes duplicate content issues with /, /index, ?utm=..., etc.

## Going deeper
- SEO Basics: https://www.codebooks.ai/seo
- Meta Tag Generator: https://www.codebooks.ai/meta-tags
- Analytics: https://www.codebooks.ai/analytics


---

This skill is part of the **CodeBooks Vibe Coding Skills Library**.
Browse all skills, install guides, and the source chapters at
https://www.codebooks.ai/skills
