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.
Most of what search engines and social apps learn about your site passes through one file. lib/config.ts holds APP_CONFIG plus the helpers that turn it into page metadata and Open Graph tags, canonical URLs, the sitemap, robots.txt, and JSON-LD structured data.
APP_CONFIG
lib/config.ts exports APP_CONFIG, the defaults the SEO helpers read from. 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 |
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 (/apple-touch-icon.png) |
assets.images.maskIcon | Safari pinned-tab mask icon (/icon-mask.png), also the maskable manifest icon |
social.twitter | twitter:site handle + Organization sameAs |
social.github | Organization sameAs link |
noIndexRoutes | Paths that get an X-Robots-Tag: noindex header (see below) |
theme.colors.background | Light/dark theme-color via generateViewport(), plus the manifest colors |
theme.colors.primary | Color attached to the Safari mask-icon link |
development.baseUrl / production.baseUrl | Base URL for canonical, OG, sitemap, schema |
Each image path above names a file that already ships in public/, so the quickest rebrand is to overwrite those files and leave the config alone.
getBaseUrl() returns the production base URL when NODE_ENV === "production" and the development one otherwise. Canonical, Open Graph, sitemap and schema URLs are built on it. production.baseUrl ships as a placeholder, so change it before you deploy or every one of those URLs points at the example domain.
Per-page metadata
Each page exports a metadata object built with generateMeta(). One call fills in the title, description, Open Graph and Twitter tags, robots directives, canonical URL, and icons.
// app/(site)/pricing/page.tsx
import type { Metadata } from "next";
import { generateMeta } from "@/lib/config";
export const metadata: Metadata = generateMeta({
title: "Pricing",
description:
"Pick the plan that fits, upgrade when you outgrow it, and cancel whenever you want.",
pathname: "/pricing",
});The root app/layout.tsx calls generateMeta() with 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 | none | 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 | Absolute URL, or a root-relative path that gets the base URL prefixed (anything not starting with http) |
noIndex | boolean | false | Emits noindex, follow robots directives (the page stays out of the index, its links still get crawled) |
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 |
Route every page through generateMeta(). Next merges child metadata over the parent's one key at a time, so a page that exports a bare { title, description } inherits whatever canonical the root layout set, which is /. That page then tells search engines it's a duplicate of your homepage, and nothing in the build warns you.
Dynamic metadata
For pages whose title comes from data (e.g. a blog post), export an async generateMetadata() and call generateMeta() inside it:
// a route you add, e.g. app/(site)/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,
});
}Sitemap and robots.txt
app/sitemap.ts uses Next.js split sitemaps (each sitemap has an id and its own URL). It maps the static publicPages array (path, priority, changeFrequency) to URLs against the base URL, and Next serves the result at /sitemap/pages.xml. Out of the box it lists four marketing pages: /, /about, /contact, and /pricing.
To add pages, append to publicPages. For a second sitemap, say a blog, return another { id: "blog" } from generateSitemaps() and handle that id in the default export, where you fetch the slugs and map them to URLs.
app/robots.ts generates a robots.txt with a single Allow: / rule for every user agent and the /sitemap/pages.xml URL. There are no Disallow rules, and that's on purpose. A route crawlers can't fetch can't show them its noindex header either, and a blocked-but-linked URL can still end up indexed without content.
Blocking routes from indexing
noIndexRoutes in APP_CONFIG is the list of route patterns that next.config.ts gives an X-Robots-Tag: noindex response header. Crawlers stay free to fetch those routes, read the header, and follow the links on them; they just keep the pages out of search results.
Add a path here to keep it out of the index. The shipped list is in lib/config.ts: auth, API, dashboard, onboarding, plus /privacy and /terms, so drop those two if you want your legal pages indexed.
The list doesn't touch page metadata, though. To put a noindex tag in one page's <head>, pass noIndex: true to that page's generateMeta() call.
JSON-LD structured data
Structured data is what makes a page eligible for rich results: your site name in the knowledge panel, FAQ accordions under a search listing, a breadcrumb trail where the raw URL would be. lib/config.ts exports four generators for it. 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. Its description and applicationCategory, though, live inline in the generator instead of reading from APP_CONFIG, so edit those in lib/config.ts when you rebrand.
For another structured-data type, say Article or Product, write a new generator in lib/config.ts that returns a schema object, then render it on the relevant page the same way.
Viewport, theme color, and manifest
generateViewport() (exported as viewport from app/layout.tsx) sets the viewport meta plus a theme-color for each color scheme, taken from APP_CONFIG.theme.colors.background.light and .dark. Those two values are what tint the mobile browser chrome.
app/manifest.ts serves /manifest.webmanifest, which browsers read when someone installs your site to a home screen. It reuses background.light for both background_color and theme_color, and declares three icons. Two come from assets.images. The third, /icon-192.png, is hardcoded, so it has to sit in public/ even though no config key points at it. next.config.ts caches the manifest response for a day.
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 (allow all, sitemap URL) |
app/manifest.ts | /manifest.webmanifest for installable/home-screen use |
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 tests start already logged in, plus how to add and run new specs.
Logging
Server-side structured logging with Pino: pretty output in dev, JSON in production, PII redaction in both, and a non-blocking after() pattern in Server Actions.