SEO
How Next Starter handles metadata, Open Graph tags, Twitter Cards, sitemaps, robots.txt, and JSON-LD structured data through a single configuration layer in lib/config.ts.
Next Starter includes a complete SEO setup. Metadata, Open Graph tags, Twitter Cards, canonical URLs, sitemaps, robots.txt, and structured data all flow from a single configuration layer in lib/config.ts.
APP_CONFIG
lib/config.ts exports a central APP_CONFIG object that drives all SEO output. Update this object with your app's name, description, URLs, social handles, and asset paths. Everything else derives from it automatically.
// lib/config.ts
export const APP_CONFIG = {
name: "Next Starter",
description: "Your app description used as the default meta description.",
email: "contact@yourapp.com",
branding: {
hasLightLogo: true,
hasDarkLogo: true,
},
assets: {
images: {
default: "/og-image.png", // 1200x630 Open Graph image
logo: "/logo.svg",
logoDark: "/logo-dark.svg",
icon: "/icon-512.png",
favicon: "/icon.svg",
appleTouchIcon: "/apple-touch-icon.png",
maskIcon: "/icon-mask.png",
},
},
social: {
twitter: "@yourhandle",
github: "https://github.com/yourorg/yourrepo",
},
noIndexRoutes: [
"/auth/:path*",
"/api/:path*",
"/dashboard/:path*",
"/privacy",
"/terms",
"/500",
"/error",
"/auth/error",
],
theme: {
// color and font configuration
},
development: {
baseUrl: "http://localhost:3000",
},
production: {
baseUrl: "https://yourapp.com",
},
} as const;The noIndexRoutes array controls which paths are disallowed in robots.txt and receive an X-Robots-Tag: noindex, nofollow HTTP response header via next.config.ts. Add any routes you do not want indexed here.
Generating Metadata
Use generateMeta() to produce a complete Next.js Metadata object for any page. It sets the title, description, Open Graph tags, Twitter Card tags, canonical URL, and icon references in one call.
// app/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",
});Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
title | string | (none) | Page title. Formatted as "Page Title - App Name". Omit to use just the app name. |
description | string | APP_CONFIG.description | Meta description for the page. |
pathname | string | required | Path used to build the canonical URL and Open Graph URL. |
noIndex | boolean | false | Set true to add noindex, nofollow robots directives. |
type | "website" | "article" | "website" | Open Graph type. Use "article" for blog posts. |
image | string | APP_CONFIG.assets.images.default | Path or full URL for the Open Graph image. |
noCanonical | boolean | false | Set true to omit the canonical link tag. |
titleFirst | boolean | false | Reverses the title order to "App Name - Page Title". |
alternateLanguages | Record<string, string> | {} | Alternate language URLs for hreflang tags. |
Title Format
By default, titles are formatted as Page Title - App Name. Set titleFirst: true to reverse the order:
generateMeta({ title: "Dashboard", pathname: "/dashboard", titleFirst: true })
// Produces: "App Name - Dashboard"Open Graph and Twitter Cards
generateMeta() populates both Open Graph and Twitter Card tags from the same inputs. The image must be at least 1200x630 pixels for best results across platforms.
The output includes:
<meta name="description" content="Simple, transparent pricing..." />
<meta name="publisher" content="Your App Name" />
<meta name="robots" content="index, follow" />
<meta property="og:title" content="Pricing - Your App Name" />
<meta property="og:description" content="Simple, transparent pricing..." />
<meta property="og:url" content="https://yourapp.com/pricing" />
<meta property="og:site_name" content="Your App Name" />
<meta property="og:locale" content="en_US" />
<meta property="og:image" content="https://yourapp.com/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Your App Name" />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@yourhandle" />
<meta name="twitter:title" content="Pricing - Your App Name" />
<meta name="twitter:description" content="Simple, transparent pricing..." />
<meta name="twitter:image" content="https://yourapp.com/og-image.png" />To use a custom image for a specific page, pass an absolute URL or a root-relative path:
export const metadata: Metadata = generateMeta({
title: "Blog Post Title",
pathname: "/blog/my-post",
type: "article",
image: "/blog/my-post-og.png",
});Canonical URLs
Canonical URLs are set automatically from the pathname argument combined with the base URL in APP_CONFIG. You do not need to set them manually.
// pathname: "/pricing" + baseUrl: "https://yourapp.com"
// Produces: <link rel="canonical" href="https://yourapp.com/pricing" />Pass noCanonical: true for pages like paginated lists where you want to manage canonical links yourself, or for pages that should not declare a canonical.
Per-Page Metadata
Each page in the app/ directory can export its own metadata object using generateMeta(). For pages that need dynamic metadata (for example, a blog post whose title comes from a database), use generateMetadata():
// app/blog/[slug]/page.tsx
import { generateMeta } from "@/lib/config";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): 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
The sitemap is generated at app/sitemap.ts. It uses Next.js's split-sitemap format, where each logical group of pages is a separate XML file.
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { getBaseUrl } from "@/lib/config";
const publicPages = [
{ path: "/", priority: 1.0, changeFrequency: "daily" as const },
{ path: "/about", priority: 0.8, changeFrequency: "monthly" as const },
{ path: "/contact", priority: 0.8, changeFrequency: "monthly" as const },
{ path: "/pricing", priority: 0.9, changeFrequency: "monthly" as const },
];
export async function generateSitemaps() {
return [{ id: "pages" }];
}
export default async function sitemap(props: {
id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
const id = await props.id;
const baseUrl = getBaseUrl();
if (id === "pages") {
return publicPages.map((page) => {
const pathPart = page.path === "/" ? "" : page.path;
return {
url: `${baseUrl}${pathPart}`,
changeFrequency: page.changeFrequency,
priority: page.priority,
};
});
}
return [];
}The sitemap is accessible at /sitemap/pages.xml. To add more sitemaps (for example, a blog sitemap), add a new entry to generateSitemaps() and handle the new id in the default export:
export async function generateSitemaps() {
return [{ id: "pages" }, { id: "blog" }];
}
export default async function sitemap(props: {
id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
const id = await props.id;
const baseUrl = getBaseUrl();
if (id === "pages") {
// ... static pages
}
if (id === "blog") {
const posts = await getAllPosts();
return posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
changeFrequency: "weekly",
priority: 0.7,
}));
}
return [];
}robots.txt
app/robots.ts generates robots.txt automatically. It reads noIndexRoutes from APP_CONFIG and disallows those paths for all crawlers. The sitemap URL is included.
// app/robots.ts
import { MetadataRoute } from "next";
import { getBaseUrl, APP_CONFIG } from "@/lib/config";
const baseUrl = getBaseUrl();
function convertToRobotsPattern(route: string): string {
return route.replace("/:path*", "/*");
}
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: APP_CONFIG.noIndexRoutes.map(convertToRobotsPattern),
},
],
sitemap: `${baseUrl}/sitemap/pages.xml`,
};
}The generated robots.txt output:
User-agent: *
Allow: /
Disallow: /auth/*
Disallow: /api/*
Disallow: /dashboard/*
Disallow: /privacy
Disallow: /terms
Disallow: /500
Disallow: /error
Disallow: /auth/error
Sitemap: https://yourapp.com/sitemap/pages.xmlTo block additional paths from crawlers, add them to noIndexRoutes in APP_CONFIG. The same list applies to both robots.ts and the X-Robots-Tag HTTP response headers in next.config.ts.
JSON-LD Structured Data
lib/config.ts provides two structured data generators for the marketing homepage.
WebApplication Schema
generateWebApplicationSchema() produces a WebApplication schema. It pulls pricing data from lib/pricing.ts and feature lists from your plan configuration automatically.
// app/(site)/page.tsx
import { generateWebApplicationSchema } from "@/lib/config";
export default function Home() {
return (
<div>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(generateWebApplicationSchema()) }}
/>
{/* page content */}
</div>
);
}The output:
{
"@context": "https://schema.org",
"@type": "WebApplication",
"@id": "https://yourapp.com/#webapp",
"name": "Your App Name",
"description": "...",
"url": "https://yourapp.com",
"image": "https://yourapp.com/icon-512.png",
"screenshot": "https://yourapp.com/og-image.png",
"browserRequirements": "Requires a modern web browser",
"inLanguage": "en",
"applicationCategory": "BusinessApplication",
"operatingSystem": "Any",
"featureList": "3 projects, 1 GB storage, Basic analytics, ...",
"offers": {
"@type": "AggregateOffer",
"lowPrice": "0",
"highPrice": "30",
"priceCurrency": "USD",
"offerCount": 3
},
"publisher": {
"@type": "Organization",
"name": "Your App Name",
"url": "https://yourapp.com"
}
}The lowPrice and highPrice values are derived from the monthly prices in lib/pricing.ts. Update your pricing tiers there and the schema updates automatically.
Organization Schema
generateOrganizationSchema() produces an Organization schema. In the default setup it renders in app/layout.tsx, so it appears on every page:
// app/layout.tsx
import { generateOrganizationSchema } from "@/lib/config";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(generateOrganizationSchema()) }}
/>
</body>
</html>
);
}{
"@context": "https://schema.org",
"@type": "Organization",
"name": "Your App Name",
"url": "https://yourapp.com",
"logo": "/icon-512.png",
"description": "Your app description.",
"sameAs": [
"https://github.com/yourorg/yourrepo",
"https://twitter.com/yourhandle"
]
}Viewport Configuration
Export generateViewport() from lib/config.ts in your root layout to set the viewport meta tag and theme color for both light and dark mode:
// app/layout.tsx
import { generateViewport } from "@/lib/config";
import type { Viewport } from "next";
export const viewport: Viewport = generateViewport();This sets theme-color to the light or dark background color based on prefers-color-scheme, which controls the browser chrome color on mobile devices.
Docker Deployment
Self-host Next Starter with Docker using the included Dockerfile, docker-compose, and Next.js standalone output mode. Covers building, environment variables, and running database migrations.
Logging
How Next Starter uses Pino for structured JSON logging in server actions and API routes, with automatic sensitive data redaction in production.