SEO + SAO — Persimmon Patterns
For public-facing client marketing sites at launch. Internal tools and admin panels do NOT get this — they noindex (see Anti-patterns). Performance/CWV defers to quality-performance; image work to client-image-optimization.
Core Rules
- Metadata via the Next 16 Metadata API, not hand-written
<head>tags. Exportmetadata(static) orgenerateMetadata(dynamic) from each route. - One canonical per page, always set. Use
metadataBase+ per-routealternates.canonical. - JSON-LD via a
<script type="application/ld+json">rendered in a Server Component — neverdangerouslySetInnerHTMLon the client, never a third-party lib. app/sitemap.tsandapp/robots.ts, not static XML files. Next generates/sitemap.xmland/robots.txt.- Demo/staging sites must
noindexat the metadata layer AND viaX-Robots-Tagheader.
Metadata API
Root layout — set metadataBase + defaults
// src/app/layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://clientdomain.com"),
title: { default: "Brand Name", template: "%s | Brand Name" },
description: "Default 150–160 char description with a CTA verb.",
openGraph: { type: "website", siteName: "Brand Name", locale: "en_US" },
twitter: { card: "summary_large_image" },
icons: { icon: "/favicon.ico", apple: "/apple-touch-icon.png" },
};
Per-page — static
// src/app/about/page.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About", // → "About | Brand Name"
description: "Unique 150–160 chars, CTA included.",
alternates: { canonical: "/about" }, // resolved against metadataBase
openGraph: { url: "/about", title: "About | Brand Name", images: ["/og/about.jpg"] },
};
Per-page — dynamic (generateMetadata)
// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";
import { db } from "@/lib/db";
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> },
): Promise<Metadata> {
const { slug } = await params;
const post = await db.post.findUnique({ where: { slug } });
if (!post) return {};
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${slug}` },
openGraph: {
type: "article",
url: `/blog/${slug}`,
title: post.title,
images: [post.heroImage],
publishedTime: post.publishedAt.toISOString(),
},
};
}
Title: 50–60 chars, keyword near the start, brand via the template.
Description: 150–160 chars, include a CTA verb, unique per page.
Never set keywords — deprecated, counterproductive.
JSON-LD (Server Component)
Render structured data as a script tag inside an RSC. Build the object in JS and JSON.stringify it. Validate with Google Rich Results Test + Schema.org Validator.
// src/components/JsonLd.tsx (Server Component — no "use client")
export function JsonLd({ data }: { data: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}
dangerouslySetInnerHTML here is safe and standard: the input is your own structured object, not user HTML. Drop <JsonLd data={...} /> into the page body.
LocalBusiness (most clients — homepage)
<JsonLd data={{
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: "Business Name",
url: "https://clientdomain.com",
telephone: "+1-555-555-5555",
email: "contact@clientdomain.com",
address: {
"@type": "PostalAddress",
streetAddress: "123 Main Street",
addressLocality: "City", addressRegion: "ST",
postalCode: "00000", addressCountry: "US",
},
openingHoursSpecification: [{
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday","Tuesday","Wednesday","Thursday","Friday"],
opens: "09:00", closes: "17:00",
}],
image: "https://clientdomain.com/images/business.jpg",
priceRange: "$$",
}} />
FAQPage (high SAO leverage)
<JsonLd data={{
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: [
{ "@type": "Question", name: "What services do you offer?",
acceptedAnswer: { "@type": "Answer", text: "Plain-language answer." } },
],
}} />
Ship FAQPage on any page with a Q&A section — Google surfaces rich results and AI agents (ChatGPT, Perplexity) cite them directly.
Article (blog posts)
<JsonLd data={{
"@context": "https://schema.org",
"@type": "Article",
headline: post.title,
image: post.heroImage,
datePublished: post.publishedAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
author: { "@type": "Person", name: post.authorName },
publisher: { "@type": "Organization", name: "Brand Name",
logo: { "@type": "ImageObject", url: "https://clientdomain.com/logo.png" } },
}} />
Also useful: Service (@type: "Service" with hasOfferCatalog) for service pages.
app/sitemap.ts
// src/app/sitemap.ts
import type { MetadataRoute } from "next";
import { db } from "@/lib/db";
export const dynamic = "force-dynamic"; // it reads the DB
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = "https://clientdomain.com";
const staticRoutes: MetadataRoute.Sitemap = [
{ url: `${base}/`, changeFrequency: "weekly", priority: 1.0 },
{ url: `${base}/about`, changeFrequency: "monthly", priority: 0.8 },
{ url: `${base}/services`, changeFrequency: "monthly", priority: 0.9 },
{ url: `${base}/contact`, changeFrequency: "monthly", priority: 0.7 },
];
const posts = await db.post.findMany({
where: { published: true },
select: { slug: true, updatedAt: true },
});
return [
...staticRoutes,
...posts.map((p) => ({
url: `${base}/blog/${p.slug}`,
lastModified: p.updatedAt,
changeFrequency: "monthly" as const,
priority: 0.6,
})),
];
}
Next serves this at /sitemap.xml automatically — no .htaccess rewrite needed.
app/robots.ts
// src/app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: ["/admin/", "/api/"] },
sitemap: "https://clientdomain.com/sitemap.xml",
};
}
For a staging/demo deployment, block everything and also send the header:
// staging robots.ts
export default function robots(): MetadataRoute.Robots {
return { rules: { userAgent: "*", disallow: "/" } };
}
// + next.config.ts: add header X-Robots-Tag: noindex, nofollow on staging
Search Agent Optimization (SAO)
Getting cited by ChatGPT, Perplexity, Google AI Overviews, Claude:
- Answerable content. Lead each page with a clear factual answer to a likely question, not a hero pitch — AI agents quote you standalone.
- Schema matters more. FAQPage, HowTo, Article get cited disproportionately.
- Stable, descriptive URLs. Don't change them.
- H2s phrased as questions ("How long does the process take?"); include data, dates, numbers; cite sources.
llms.txt
A markdown index AI agents read — robots.txt for LLMs. Serve it as a static file or a route handler at /llms.txt.
// src/app/llms.txt/route.ts
export function GET(): Response {
const body = `# Brand Name
> One-sentence company description.
## About
- [About us](https://clientdomain.com/about): History and mission.
## Services
- [Service A](https://clientdomain.com/services/a): What it does.
## Contact
- [Contact](https://clientdomain.com/contact): How to reach us.
## Key Pages
- [Pricing](https://clientdomain.com/pricing): Pricing structure.
- [FAQ](https://clientdomain.com/faq): Common questions.
`;
return new Response(body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
}
Ship on every public marketing site — ~10 minutes, upside is Perplexity/ChatGPT citations.
Per-page Checklist
- Unique
title(50–60 chars) via Metadata API - Unique
description(150–160 chars, CTA) -
alternates.canonicalset - Open Graph + Twitter card (inherited from layout, overridden per page)
- Relevant JSON-LD (LocalBusiness home; FAQPage on Q&A; Article on blog)
- One H1; H2/H3 hierarchy
- Alt text on every image (
client-image-optimization)
Launch Checklist
-
/sitemap.xmlresolves (fromapp/sitemap.ts) -
/robots.txtresolves (fromapp/robots.ts) -
/llms.txtresolves -
metadataBaseis the production https origin (no http, no www mismatch) - Google Search Console verified + sitemap submitted
- HTTPS enforced; LCP < 2.5s (see
quality-performance) - No broken links
Anti-patterns banned
- Hand-written
<head>meta tags instead of the Metadata API. keywordsmeta (deprecated, counterproductive).- Duplicate titles/descriptions across pages.
- JSON-LD with unfilled placeholder text (Google flags as spammy).
- Missing canonical (duplicate-content penalty).
noindexon a production marketing page (kills SEO).- Indexing internal tools / admin / staging — those must
noindex. - One sitemap shared across multiple client sites — each site gets its own.
- Forgetting the
sitemapline inrobots.ts.
Cross-references
- client-analytics — pairs with SEO at launch (both touch
<head>/ layout). - client-image-optimization — alt text,
next/image, WebP affect SEO. - quality-performance — CWV/LCP that SEO scoring depends on.
- quality-final-review — launch checklist includes these SEO items.