Next.js
How to Build a Next.js Website for Production: App Router, Data, Caching, and Deployment
Build a production Next.js website with the App Router, Server Components, explicit caching, secure mutations, SEO, testing, and deployment.
A production Next.js site is more than a collection of React components. Its routing, server and client boundaries, data lifetime, metadata, failure states, and deployment contract all need to agree.
1. Decide what the website must do
Start with the website's responsibilities, not its component library. List the public routes, private routes, content sources, mutations, search requirements, and external services. This tells you which pages can be static, which need request-time data, and where authorization must happen.
For a typical product website, the marketing pages and documentation change relatively slowly, a blog is generated from content, and an account area depends on the signed-in user. Those are three different data lifetimes. Treating all of them as a client-rendered single-page application throws away useful server rendering; treating all of them as static HTML makes the account area impossible.
Write the route map before implementation. Give every important page one primary job and one canonical URL. Decide which entities need dynamic segments, such as /blog/[slug], and which UI should persist across navigation in a shared layout.
- —Public content: home, product, pricing, about, blog, guides, and legal pages.
- —Application content: dashboard, settings, billing, or other session-dependent routes.
- —Data boundaries: local files, a CMS, a database, external APIs, and user input.
- —Operational needs: environment variables, health checks, logs, scheduled work, and deployment.
2. Create the project with deliberate defaults
This guide uses the Next.js 16 App Router, TypeScript, and a src directory. The current create-next-app defaults are sensible for a new project, but explicit flags make the setup repeatable for teammates and CI.
Run the development server, then make a production build immediately. A first-day build catches Node.js version problems, unsupported imports, and configuration errors before the project grows around them. Keep npm's lockfile in source control and use npm ci in automated builds so dependency resolution stays repeatable.
Terminal
npx create-next-app@latest northstar \
--ts --tailwind --eslint --app --src-dir \
--import-alias "@/*"
cd northstar
npm run dev
npm run build3. Organize routes around user journeys
The App Router turns folders into URL segments. A page.tsx file makes a route public; layout.tsx wraps that segment and its descendants; loading.tsx supplies a streaming fallback; error.tsx catches uncaught rendering errors; and not-found.tsx handles missing resources. Put files near the route that owns them rather than building one global components folder with no clear boundaries.
Route groups such as (marketing) and (app) organize folders without changing the URL. They are useful when the public site and signed-in product need different layouts. Dynamic segments such as [slug] receive route parameters, while catch-all segments such as [...parts] capture several levels.
Use advanced parallel and intercepting routes only when the interaction needs them. A shareable photo page that opens as a modal during in-app navigation is a good fit. A normal settings page is not. The simplest route tree that matches the user's mental model is usually the easiest one to debug.
A practical App Router structure
src/app/
├── layout.tsx
├── globals.css
├── (marketing)/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── pricing/page.tsx
│ └── blog/
│ ├── page.tsx
│ └── [slug]/page.tsx
├── (app)/
│ ├── dashboard/page.tsx
│ ├── settings/page.tsx
│ └── loading.tsx
├── api/health/route.ts
├── not-found.tsx
└── global-error.tsx4. Keep the root layout small and stable
The root layout is required and owns the html and body elements. Put truly global concerns there: the document language, site-wide font variables, global CSS, a theme provider when one is necessary, and shared metadata defaults. Do not make the root layout a dumping ground for route-specific queries or a large client-side provider tree.
Layouts persist during client navigation, so they are the right place for stable navigation and shells. A template.tsx file behaves differently: it receives a new key and remounts when its segment changes. Choose a template only when reset-on-navigation behavior is intentional, such as restarting an entrance animation or resetting local state.
src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], display: "swap" });
export const metadata: Metadata = {
metadataBase: new URL("https://example.com"),
title: { default: "Northstar", template: "%s | Northstar" },
description: "Planning software for focused product teams.",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}5. Make Server Components the default
Pages and layouts are Server Components unless you mark a module with the use client directive. They can query a database, read server-only environment variables, call internal services, and send rendered output without adding that component's code to the browser bundle.
Add a Client Component at the smallest useful interactive boundary. A filter control may need state, event handlers, and the URL APIs; the product grid below it may remain a Server Component. Marking the whole page as a Client Component moves more JavaScript and more data-fetching responsibility into the browser than the interaction requires.
Props crossing from server to client must be serializable. Pass a narrow view model rather than a database record with private fields. Add an import of server-only to data modules that must never enter a client bundle; this turns an accidental boundary violation into a build error.
A small client island inside a server-rendered page
// src/app/products/page.tsx — Server Component
import { getProducts } from "@/lib/data";
import ProductFilters from "./product-filters";
export default async function ProductsPage() {
const products = await getProducts();
return <ProductFilters products={products} />;
}
// src/app/products/product-filters.tsx — Client Component
"use client";
import { useState } from "react";
export default function ProductFilters({ products }) {
const [query, setQuery] = useState("");
const visible = products.filter((product) =>
product.name.toLowerCase().includes(query.toLowerCase()),
);
return <>{/* input and product list */}</>;
}6. Fetch data where it is rendered
An async Server Component can call fetch, an ORM, or a database client directly. This removes the need to create an internal HTTP endpoint only so server-rendered code can call itself. Keep the query in a server-only data access module when several routes use it or when authorization and output shaping need one audited home.
Avoid request waterfalls. If two queries are independent, start them together with Promise.all. If a child component alone needs slower data, let that component fetch its own data and place a Suspense boundary around it. The page can then send useful HTML while the slower region finishes.
Decide how fresh each result must be before adding a cache. User-specific account data generally belongs at request time. A public pricing table may be cached. A product catalog might use a tagged cache that is invalidated after an editor publishes a change.
Parallel server-side data fetching
import "server-only";
export default async function DashboardPage() {
const [account, activity] = await Promise.all([
getAccount(),
getRecentActivity(),
]);
return <Dashboard account={account} activity={activity} />;
}7. Treat caching as a product decision
Next.js 16 makes Cache Components opt-in. When cacheComponents is enabled, the use cache directive can cache an async function or component. cacheLife defines its lifetime, cacheTag gives related entries a shared invalidation key, and a Suspense boundary streams uncached request-time work beside the cached shell.
Caching is not automatically correct because content is public. Ask what happens when the result is stale, how it is invalidated, whether the cache is shared by the deployment platform, and whether any session value can enter the cache key. Never cache one user's private result under a key another user can receive.
Use updateTag from a Server Action when the same user must see a mutation immediately. Use revalidateTag with an appropriate cache profile when stale-while-revalidate behavior is acceptable, or revalidatePath when the invalidation boundary is naturally a page or layout. Keep invalidation next to the mutation that makes cached data stale.
next.config.ts and src/lib/products.ts
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = { cacheComponents: true };
export default nextConfig;
// src/lib/products.ts
import "server-only";
import { cacheLife, cacheTag } from "next/cache";
export async function getProducts() {
"use cache";
cacheLife("hours");
cacheTag("products");
return db.product.findMany();
}8. Design loading, empty, error, and missing states
The happy path is only one state. loading.tsx creates a route-level Suspense boundary and lets navigation show an immediate fallback. Add smaller Suspense boundaries when independent regions should reveal themselves separately. A useful skeleton preserves the final layout instead of replacing the page with a spinner that causes a large shift.
Expected failures belong in normal control flow. A form validation error should return a typed result to the form. A missing record should call notFound. A permission failure should produce the appropriate response or redirect. Reserve error.tsx for uncaught exceptions, log the error with enough request and release context to investigate it, and give the user a safe recovery action.
Empty results are not errors. A new account with no projects should explain what belongs on the page and how to create the first one. That small distinction makes the interface easier to understand and keeps monitoring focused on actual failures.
src/app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
export default async function PostPage({ params }) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound();
return <article>{/* post content */}</article>;
}9. Handle mutations and HTTP endpoints explicitly
Server Actions are a direct fit for mutations triggered by your React interface. Mark the function with use server, validate FormData on the server, verify the user's session and permission, perform the write, then invalidate the affected cache. Treat every exported Server Action as a public endpoint: hiding the button is not authorization.
Use Route Handlers for HTTP interfaces that exist beyond one React form, including webhooks, health checks, file responses, and endpoints called by mobile apps or third parties. Export handlers for the required HTTP verbs and return standard Web Response objects. Validate signatures before parsing trusted webhook fields and make retried operations idempotent.
Keep side effects out of rendering. Sending email, charging a card, or writing analytics from a component body can run more than once. Perform those actions in a mutation boundary, record enough state to make retries safe, and move long-running work to a queue when the request should not wait for it.
src/app/projects/actions.ts
"use server";
import { updateTag } from "next/cache";
export async function createProject(formData: FormData) {
const session = await verifySession();
if (!session) throw new Error("Unauthorized");
const input = projectSchema.parse({
name: formData.get("name"),
});
await db.project.create({ data: { ...input, ownerId: session.userId } });
updateTag("projects");
}10. Build search and sharing metadata into each route
Search optimization begins with a crawlable, useful page and a stable URL. Give every indexable route a specific title, a plain-language description, one visible H1, meaningful headings, descriptive links, and content that fully answers the query. Metadata cannot compensate for a thin page or several URLs publishing the same content.
Export a static metadata object when values are fixed. Use generateMetadata when a title, description, canonical URL, or image depends on route data. Set metadataBase once in the root layout so relative canonical and social image URLs resolve correctly. Generate a sitemap and robots file from the same source that defines public routes, and exclude private or duplicate URLs from indexing.
Add Article, Product, BreadcrumbList, or another appropriate JSON-LD type only when the visible page supports it. Structured data should describe what the reader can actually see. Validate it after rendering and update dateModified when substantial content changes.
- —Use app/robots.ts and app/sitemap.ts for generated crawler files.
- —Add opengraph-image and twitter-image files when a route needs generated social artwork.
- —Redirect retired URLs and pick one canonical host, protocol, and trailing-slash policy.
- —Link related pages together with descriptive anchor text so users and crawlers can discover them.
Metadata for a dynamic article
import type { Metadata } from "next";
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
if (!post) return { title: "Article not found" };
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: "/blog/" + post.slug },
openGraph: {
type: "article",
title: post.title,
description: post.excerpt,
images: [post.ogImage],
},
};
}11. Protect performance at the component boundary
Next.js supplies route-level code splitting, Server Components, prefetching, image optimization, and font tooling, but application choices still determine the result. Watch the client boundary: a use client directive pulls that module and its client-side imports into the browser graph. Large charting libraries, editors, maps, and analytics packages deserve deliberate loading strategies.
Use next/image with real dimensions or a controlled fill container so the browser can reserve space. Use next/font to self-host and preload the font files your design actually needs. Load third-party scripts with next/script and the least aggressive strategy that still meets the business requirement.
Measure production behavior, not only the development server. Run Lighthouse as a lab check, collect Core Web Vitals from real visits, inspect slow server queries, and analyze the client bundle when a route regresses. Performance budgets are more useful when they name a route and a metric instead of one site-wide score.
A responsive image with reserved space
import Image from "next/image";
<Image
src="/product-dashboard.png"
alt="Northstar dashboard showing the weekly plan"
width={1600}
height={900}
sizes="(max-width: 768px) 100vw, 800px"
priority
/>12. Put authentication beside data access
Authentication proves identity; authorization decides what that identity may do. Use a maintained authentication library unless the product has a strong reason to own password handling, session rotation, account recovery, and provider integration. Store session material in secure, HTTP-only cookies and keep sensitive reads on the server.
Centralize secure authorization in a data access layer and check it again inside every Server Action and Route Handler. Proxy can perform optimistic redirects near the route boundary, but it is not the only protection: users can still call public mutation endpoints directly, and nested server code needs its own checks.
Only environment variables prefixed with NEXT_PUBLIC_ belong in browser code. Treat those values as public at build time. Mark private data modules with server-only, return narrow DTOs to Client Components, validate every input, and escape or sanitize untrusted rich content before rendering it.
- —Use secure, HTTP-only, same-site cookies for sessions where the auth design allows it.
- —Check ownership or role at the point of every sensitive read and write.
- —Verify webhook signatures against the raw request body when the provider requires it.
- —Add a Content Security Policy that matches the scripts and resources the site truly loads.
- —Never place secrets, private database records, or unrestricted error details in Client Component props.
13. Test behavior at the right level
Unit-test pure business rules without involving Next.js. Integration-test the data layer, Server Actions, and Route Handlers against realistic boundaries. Use browser tests for the small number of journeys whose failure would stop a user: sign in, create the primary resource, complete checkout, or publish content.
Accessibility belongs in implementation and review, not one final scan. Use semantic elements, visible keyboard focus, associated form labels, useful error messages, sufficient contrast, and reduced-motion behavior. Automated checks catch a subset; keyboard and screen-reader passes expose problems the component tree alone cannot show.
Make the production build part of continuous integration. Next.js 16 no longer treats next build as the place that runs your linter, so run linting, type checking, tests, and the production build as explicit commands. Start the built app and smoke-test its health and critical routes before promotion.
A straightforward CI verification sequence
npm ci
npm run lint
npx tsc --noEmit
npm test
npm run build
npm start14. Deploy the process you tested
A server-rendered Next.js app needs a compatible Node.js runtime, its build output, environment values, a start command, and a health check. Build from a clean checkout with the committed lockfile. Keep private values in the deployment platform's secret store, not in the repository or image.
A health route should answer whether this release can receive traffic. Keep it cheap and avoid returning secrets or detailed internals. Inspect build and runtime logs separately, verify the generated route, then connect the custom domain and managed TLS. If the app relies on local disk, background jobs, image optimization, or Cache Components, confirm that the hosting environment supports the behavior you chose.
Adios runs the normal Next.js production server as a persistent Node.js process. The deployment manifest keeps the build, start, port, runtime, and health contract beside the source, so the same assumptions can be reviewed before a release.
adios.yaml
name: northstar
region: de
replicas: 1
build_cmd: npm ci && npm run build
start_cmd: npm start
runtime:
name: node@24
port: 3000
health_path: /api/health
memory_mb: 1024
secrets:
DATABASE_URL: secret://DATABASE_URL
AUTH_SECRET: secret://AUTH_SECRET15. Use a release checklist
The final review should connect product behavior to runtime behavior. Test a clean build, a direct visit to each critical route, client navigation between layouts, a slow dependency, an expected validation error, an uncaught error, a missing record, and an unauthorized request. View the HTML source of public pages to confirm that important content and metadata are present without waiting for client JavaScript.
Then test recovery. Stop a required dependency, submit the same mutation twice, rotate a secret, and deploy a version that fails its health check. A website is ready when the team can explain not only how it starts, but how it fails, how users are protected during that failure, and how the last healthy release remains available.
- —Routes: canonical URLs, redirects, 404 behavior, sitemap, and robots rules are correct.
- —Rendering: server/client boundaries are intentional and slow sections stream useful fallbacks.
- —Data: caching, invalidation, authorization, empty states, and error states match the product.
- —Quality: lint, types, tests, accessibility, production build, and smoke tests pass.
- —Operations: secrets, health, logs, domain, TLS, rollback, and ownership are documented.