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:
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:
// 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:
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):
// 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
// 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
// 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:
// 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:
{
"@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:
{
"@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:
{
"@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)
// 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:
- 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.
- Internal linking — link each programmatic page to related ones ("See also: Best moisturizer for dry skin", "Compare to: Best for combo").
- Submit them all to Google Search Console — add the sitemap that includes them, watch the indexing report.
- Hreflang if multilingual — add
<link rel="alternate" hreflang="...">in your generateMetadata. - 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:
// 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:
- Page speed — slow sites get demoted. Optimize images, use
next/image, minimize JS. - Mobile-friendly — Google indexes mobile-first.
- HTTPS — Vercel/Netlify give you this for free.
- Unique titles and descriptions — no two pages should have the same.
- Semantic HTML —
<h1>,<article>,<nav>,<main>matter. - Internal linking — link between your pages with descriptive anchor text.
- Canonical URLs — prevent duplicate content issues.
- Sitemap submitted to Search Console — helps Google find new pages fast.
- Structured data — gets you rich results.
- 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: noindexto 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
