Set up SEO infrastructure: $ARGUMENTS
$ARGUMENTS can include:
- "audit" — scan existing pages for missing metadata and fix
- "full" — complete setup: metadata + sitemap + robots + structured data + OG images (default)
- "metadata-only" — just the metadata patterns, no sitemap or OG images
- "with-blog" — include article/blog structured data patterns
- Empty — full setup
Before Starting
- Read
src/app/layout.tsx— check existing metadata config - Read
src/lib/config/tenant.config.ts— get brand name, description, domain - Read
src/lib/env.ts— check forNEXT_PUBLIC_APP_URL - Read
src/app/(public)/— catalog all public pages that need metadata - Check if
src/app/sitemap.tsorsrc/app/robots.tsalready exist
Architecture
src/app/
layout.tsx ← Root metadata (title template, OG defaults)
sitemap.ts ← Dynamic sitemap — fetches all public URLs
robots.ts ← robots.txt
opengraph-image.tsx ← Default OG image (Next.js ImageResponse)
(public)/
page.tsx ← Per-page metadata via generateMetadata()
articles/[slug]/
page.tsx ← Dynamic metadata from DB
opengraph-image.tsx ← Per-article OG image
src/lib/seo/
metadata.ts ← buildMetadata() helper
structured-data.ts ← JSON-LD builders (Article, Person, Organization)
og-image.ts ← OG image generation helpers
Step 1 — Root Layout Metadata
Edit src/app/layout.tsx — add metadata export:
import type { Metadata } from "next";
import { tenantConfig } from "@/lib/config/tenant.config";
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://yourdomain.com";
export const metadata: Metadata = {
metadataBase: new URL(APP_URL),
title: {
default: tenantConfig.name,
template: `%s | ${tenantConfig.name}`,
},
description: tenantConfig.tagline,
openGraph: {
type: "website",
siteName: tenantConfig.name,
title: tenantConfig.name,
description: tenantConfig.tagline,
url: APP_URL,
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
alt: tenantConfig.name,
},
],
},
twitter: {
card: "summary_large_image",
title: tenantConfig.name,
description: tenantConfig.tagline,
images: ["/opengraph-image"],
},
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true, "max-image-preview": "large" },
},
alternates: {
canonical: APP_URL,
},
};
Step 2 — buildMetadata Helper
Create src/lib/seo/metadata.ts:
import type { Metadata } from "next";
import { tenantConfig } from "@/lib/config/tenant.config";
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://yourdomain.com";
interface BuildMetadataOptions {
title: string;
description?: string;
path?: string; // e.g. "/articles/my-article"
image?: string; // Absolute URL or path
noIndex?: boolean;
type?: "website" | "article";
publishedAt?: string; // ISO date for articles
authors?: string[];
}
export function buildMetadata(opts: BuildMetadataOptions): Metadata {
const {
title,
description = tenantConfig.tagline,
path = "",
image = "/opengraph-image",
noIndex = false,
type = "website",
publishedAt,
authors,
} = opts;
const url = `${APP_URL}${path}`;
const imageUrl = image.startsWith("http") ? image : `${APP_URL}${image}`;
return {
title,
description,
alternates: { canonical: url },
openGraph: {
type,
title,
description,
url,
siteName: tenantConfig.name,
images: [{ url: imageUrl, width: 1200, height: 630, alt: title }],
...(publishedAt && { publishedTime: publishedAt }),
...(authors && { authors }),
},
twitter: {
card: "summary_large_image",
title,
description,
images: [imageUrl],
},
robots: noIndex
? { index: false, follow: false }
: { index: true, follow: true },
};
}
Step 3 — Per-Page Metadata (static)
Example for a static public page:
// src/app/(public)/about/page.tsx
import { buildMetadata } from "@/lib/seo/metadata";
export const metadata = buildMetadata({
title: "About",
description: "Learn about our mission and approach.",
path: "/about",
});
export default function AboutPage() { ... }
Step 4 — Dynamic Metadata (from DB)
For dynamic routes like /articles/[slug]:
// src/app/(public)/articles/[slug]/page.tsx
import { buildMetadata } from "@/lib/seo/metadata";
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const article = await getArticleBySlug(slug); // your service
if (!article) return buildMetadata({ title: "Not found", noIndex: true });
return buildMetadata({
title: article.title,
description: article.excerpt ?? undefined,
path: `/articles/${slug}`,
image: article.heroImageUrl ?? undefined,
type: "article",
publishedAt: article.publishedAt?.toISOString(),
authors: [article.authorName],
});
}
Step 5 — Default OG Image
Create src/app/opengraph-image.tsx:
import { ImageResponse } from "next/og";
import { tenantConfig } from "@/lib/config/tenant.config";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image() {
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
width: "100%",
height: "100%",
backgroundColor: "#000",
color: "#fff",
fontFamily: "sans-serif",
padding: "60px",
}}
>
<div style={{ fontSize: 72, fontWeight: 700, marginBottom: 24 }}>
{tenantConfig.name}
</div>
<div style={{ fontSize: 32, opacity: 0.7, textAlign: "center" }}>
{tenantConfig.tagline}
</div>
</div>
),
size
);
}
For custom fonts, load via fetch() in the ImageResponse function.
Step 6 — Dynamic Sitemap
Create src/app/sitemap.ts:
import type { MetadataRoute } from "next";
import { db } from "@/lib/database/db";
import { articles, courses, frameworks } from "@/lib/database/schema";
import { eq } from "drizzle-orm";
import { getTenantOrgId } from "@/lib/tenant";
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://yourdomain.com";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const orgId = getTenantOrgId();
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{ url: APP_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
{ url: `${APP_URL}/about`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 },
{ url: `${APP_URL}/articles`, lastModified: new Date(), changeFrequency: "daily", priority: 0.9 },
];
// Dynamic: articles
const articleRows = await db
.select({ slug: articles.slug, updatedAt: articles.updatedAt })
.from(articles)
.where(eq(articles.organizationId, orgId));
const articlePages: MetadataRoute.Sitemap = articleRows.map((a) => ({
url: `${APP_URL}/articles/${a.slug}`,
lastModified: a.updatedAt ?? new Date(),
changeFrequency: "weekly",
priority: 0.7,
}));
return [...staticPages, ...articlePages];
}
Step 7 — robots.txt
Create src/app/robots.ts:
import type { MetadataRoute } from "next";
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://yourdomain.com";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/api/",
"/account/",
"/admin/",
"/_next/",
],
},
],
sitemap: `${APP_URL}/sitemap.xml`,
};
}
Step 8 — Structured Data (JSON-LD)
Create src/lib/seo/structured-data.ts:
import { tenantConfig } from "@/lib/config/tenant.config";
const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://yourdomain.com";
export function buildOrganizationLD() {
return {
"@context": "https://schema.org",
"@type": "Organization",
name: tenantConfig.name,
url: APP_URL,
description: tenantConfig.tagline,
};
}
export function buildArticleLD(article: {
title: string;
description?: string;
slug: string;
publishedAt?: Date;
updatedAt?: Date;
authorName?: string;
}) {
return {
"@context": "https://schema.org",
"@type": "Article",
headline: article.title,
description: article.description,
url: `${APP_URL}/articles/${article.slug}`,
datePublished: article.publishedAt?.toISOString(),
dateModified: article.updatedAt?.toISOString(),
author: article.authorName
? { "@type": "Person", name: article.authorName }
: undefined,
publisher: { "@type": "Organization", name: tenantConfig.name },
};
}
Inject in page components:
import { buildArticleLD } from "@/lib/seo/structured-data";
export default function ArticlePage({ article }) {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(buildArticleLD(article)) }}
/>
{/* page content */}
</>
);
}
Verify
pnpm typecheck— no metadata errorspnpm dev— visit/sitemap.xml→ valid XML with all URLs- Visit
/robots.txt→ correct disallow rules - Open DevTools → Network → see correct OG tags in
<head> - Use Open Graph Debugger on a deployed URL
- Use Google Rich Results Test for JSON-LD
Anti-Patterns
- NEVER use
<Head>fromnext/headin App Router — usemetadataexports - NEVER set
noIndex: trueon pages you want indexed - NEVER hardcode the domain — always use
NEXT_PUBLIC_APP_URLenv var - NEVER skip
metadataBasein root layout — relative OG image URLs break without it - NEVER generate sitemap from static data only — fetch from DB for accuracy