SEO
How Next Starter handles metadata, Open Graph, Twitter Cards, sitemaps, robots.txt, and JSON-LD structured data from a single config layer in lib/config.ts.
SEO is how search engines and social apps understand your site. This starter handles it from one place. You set your app details once in lib/config.ts, and everything below is generated for you:
- Metadata: the page title, description, and the Open Graph / Twitter tags that control how a link looks when shared.
- Canonical URLs: tell search engines the one "real" address for a page, so duplicates don't compete.
- Sitemap and
robots.txt: a map of your pages for crawlers, plus rules for which pages to skip. - JSON-LD structured data: machine-readable facts about your site (its name, FAQ answers, price range) that help search engines show rich results.
All of it derives from APP_CONFIG and a few helpers in lib/config.ts.
APP_CONFIG
lib/config.ts exports APP_CONFIG, the single source of truth for SEO output. Edit these keys for your app:
| Key | Used for |
|---|---|
name | App name in titles, OG site_name, schema, publisher |
description | Default meta description and Organization schema |
email | Contact address surfaced elsewhere in the app |
assets.images.default | Open Graph / Twitter image (/og-image.png, 1200×630) |
assets.images.icon | Schema image/logo (/icon-512.png) |
assets.images.favicon | Favicon + shortcut (/icon.svg) |
assets.images.appleTouchIcon | Apple touch icon |
assets.images.maskIcon | Safari pinned-tab mask icon |
social.twitter | twitter:site handle + Organization sameAs |
social.github | Organization sameAs link |
noIndexRoutes | Paths to block from crawlers (see below) |
theme.colors.background | Light/dark theme-color via generateViewport() |
development.baseUrl / production.baseUrl | Base URL for canonical, OG, sitemap, schema |
getBaseUrl() returns the production base URL when NODE_ENV === "production", otherwise the development one. Every absolute URL in the SEO layer is built from it.
The asset config is a flat assets.images map of icon/OG paths. There is no branding block. Store your actual image files in public/ under the names referenced above (og-image.png, icon-512.png, icon.svg, apple-touch-icon.png, icon-mask.png).
Per-page metadata
Metadata is the set of tags in a page's <head> that describe it: the title, description, the Open Graph (OG) and Twitter tags for link previews, robots directives, the canonical URL, and icons.
Each page exports a metadata object built with generateMeta(). One call returns a complete Next.js Metadata object with all of those filled in.
// app/(site)/pricing/page.tsx
import { generateMeta } from "@/lib/config";
import type { Metadata } from "next";
export const metadata: Metadata = generateMeta({
title: "Pricing",
description: "Simple, transparent pricing for every team size.",
pathname: "/pricing",
});The root app/layout.tsx calls generateMeta({ pathname: "/" }) for site-wide defaults, and also exports viewport from generateViewport().
generateMeta options
| Option | Type | Default | Notes |
|---|---|---|---|
pathname | string | required | Builds the canonical and OG URL from the base URL |
title | string | — | Rendered as Title - App Name; omit for just the app name |
description | string | APP_CONFIG.description | Meta + OG + Twitter description |
type | "website" | "article" | "website" | Use "article" for blog posts |
image | string | assets.images.default | Root-relative path or absolute URL; relative paths get the base URL prefixed |
noIndex | boolean | false | Emits noindex, nofollow robots directives |
noCanonical | boolean | false | Omits the canonical tag (e.g. paginated lists) |
titleFirst | boolean | false | Reverses to App Name - Title |
alternateLanguages | Record<string, string> | {} | hreflang alternate URLs |
Canonical URLs are set automatically from pathname + base URL, with no manual work unless you pass noCanonical: true.
Dynamic metadata
For pages whose title comes from data (e.g. a blog post), export an async generateMetadata() and call generateMeta() inside it:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return generateMeta({
title: post.title,
description: post.excerpt,
pathname: `/blog/${slug}`,
type: "article",
image: post.ogImage ?? undefined,
});
}Custom OG image per page: pass image with an absolute URL or root-relative path. The default 1200×630 image lives at public/og-image.png.
Sitemap and robots.txt
A sitemap is a list of your pages that search engines read to find and crawl them. robots.txt tells crawlers which paths to skip.
app/sitemap.ts uses Next.js split sitemaps (each sitemap has an id and its own file). The static publicPages array (path, priority, changeFrequency) is mapped to URLs against the base URL, served at /sitemap/pages.xml.
Add pages: append to publicPages. Add a second sitemap (e.g. blog): return another { id: "blog" } from generateSitemaps() and handle that id in the default export, fetching slugs and mapping them to URLs.
app/robots.ts generates robots.txt from APP_CONFIG.noIndexRoutes: each route is converted (/:path* → /*) into a Disallow rule, with the sitemap URL appended.
Blocking routes from indexing
noIndexRoutes in APP_CONFIG is the single list that drives both crawler blocking surfaces:
app/robots.tsturns it intoDisallowrules.next.config.tsattaches anX-Robots-Tag: noindex, nofollowresponse header to each listed route.
Add a path here to block it everywhere at once. Defaults cover /auth/*, /api/*, /dashboard/*, /onboarding, /privacy, /terms, /500, /error, and /auth/error.
JSON-LD structured data
Structured data is machine-readable facts about a page, written in a format search engines understand (JSON-LD). It powers rich results: your site name in the knowledge panel, FAQ accordions in search, star-rated breadcrumbs. It doesn't change what users see on the page; it only describes the page to crawlers.
lib/config.ts exports four schema generators. Each returns a plain object you serialize into a <script type="application/ld+json"> tag.
| Generator | Schema type | Where it renders | Data source |
|---|---|---|---|
generateOrganizationSchema() | Organization | app/layout.tsx (every page) | APP_CONFIG name, url, icon, social |
generateWebApplicationSchema() | WebApplication | Home and pricing pages | APP_CONFIG + lib/pricing.ts (price range, features) |
generateBreadcrumbSchema(items) | BreadcrumbList | About, contact, pricing pages | items: { name, href }[] you pass |
generateFAQSchema(faqs) | FAQPage | Home and pricing pages | faqs: { question, answer }[] you pass |
Render one like this:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(generateWebApplicationSchema()),
}}
/>generateWebApplicationSchema() pulls its offers price range (lowPrice/highPrice/offerCount) and featureList from lib/pricing.ts via getLowestPrice(), getHighestPrice(), getAllFeatures(), and PRICING_TIERS. Update your tiers there and the schema follows.
Add another structured-data type (e.g. Article, Product): write a new generator in lib/config.ts returning a schema object, then render it on the relevant page the same way. Use the existing generators as templates.
Viewport and theme color
generateViewport() (exported as viewport from app/layout.tsx) sets the viewport meta and a theme-color per color scheme, pulled from APP_CONFIG.theme.colors.background.light / .dark. This controls the mobile browser chrome color. Edit those two values to change it.
Where it lives
| File | Responsibility |
|---|---|
lib/config.ts | APP_CONFIG, generateMeta(), generateViewport(), the four schema generators, getBaseUrl() |
lib/pricing.ts | Price range + feature list feeding the WebApplication schema |
app/layout.tsx | Site-wide metadata, viewport, Organization schema |
app/sitemap.ts | Split sitemaps |
app/robots.ts | robots.txt from noIndexRoutes |
next.config.ts | X-Robots-Tag headers from noIndexRoutes |
public/ | OG image and icon assets |
Testing
Run end-to-end tests with Playwright in Next Starter: a setup project signs in once so every test reuses the session, plus how to add and run new specs.
Logging
Server-side structured logging with Pino: pretty output in dev, JSON with PII redaction in production, and a non-blocking after() pattern in Server Actions.