Structuring a Next.js App Router Project for Production
A practical layout for Next.js App Router projects — routes, data access, shared components and configuration — and the reasoning behind each boundary.
The App Router gives you a lot of freedom about where code lives, and very little guidance. A small project survives any layout; a project with a dozen routes, a database and two years of changes does not. This article describes a structure that keeps routing, data access and UI separate, and explains the reasoning so you can adapt it instead of copying it.
Start with routes as the public contract
Everything inside app/ that has a page.tsx or route.ts becomes a URL. Treat the folder as the public contract of your site and keep it thin: a page should fetch what it needs, compose components, and export metadata. It should not contain query logic or business rules.
src/
app/
layout.tsx
page.tsx
blog/
page.tsx
[slug]/page.tsx
api/
contact/route.ts
sitemap.ts
robots.ts
components/
lib/
db.ts
models/
blog.ts
seo.ts
site.ts
content/Two details matter here. sitemap.ts and robots.ts live in app/ because Next.js treats them as route conventions. And everything that is not a route — components, helpers, data access — lives outside app/, so adding a file never accidentally creates a URL.
Put data access behind functions in lib/
The most useful rule is also the simplest: pages never talk to the database directly. They call a function such as getAllPosts() or getProject(slug).
// src/lib/blog.ts
export async function getPost(slug: string): Promise<Post | undefined> {
const file = getFilePosts().find((p) => p.slug === slug);
if (file) return file;
return getPostFromDatabase(slug);
}This pays off three times. The page stays readable. The same function can serve the page, the sitemap and the Open Graph image, so they never disagree. And when the source changes — from files to a database, or from one database to another — one module changes instead of every route.
For MongoDB with Mongoose, cache the connection on globalThis. In development, hot reloading re-evaluates modules and would otherwise open a new connection each time:
// src/lib/db.ts
const cache = globalThis._mongooseCache ?? { conn: null, promise: null };
globalThis._mongooseCache = cache;
export async function connectDB() {
if (cache.conn) return cache.conn;
cache.promise ??= mongoose.connect(process.env.MONGO_URI!);
cache.conn = await cache.promise;
return cache.conn;
}Decide per route: static, revalidated or dynamic
Rendering strategy is a per-route decision, and the defaults are good. A route with no request-specific input is prerendered at build time. You opt out of that only when you need to.
- Fully static — marketing pages, project pages, articles that live in the repository. Fastest, cheapest, best for crawlers.
- Revalidated — pages backed by data that changes occasionally.
export const revalidate = 3600rebuilds the page in the background at most once an hour. - Dynamic — pages that depend on cookies, headers or per-user data. Use these deliberately; one
cookies()call makes the whole route dynamic.
Dynamic routes with a known set of values should declare them with generateStaticParams, so those pages are generated at build time. Setting dynamicParams = true still lets unknown slugs render on demand, which is useful when some content comes from a database.
Keep client components small and at the leaves
By default every component is a Server Component. Add "use client" only where you need state, effects or browser APIs — a menu toggle, a form, an analytics tracker. Push that directive as far down the tree as you can, so the surrounding page stays on the server and ships no JavaScript for itself. The boundary rules are covered in more depth in Server vs Client Components in Next.js.
Centralise site-wide constants
Values like the production URL, the author name and the default social image tend to be copy-pasted into a dozen files, and one of them is always stale. Put them in a single module:
// src/lib/site.ts
export const SITE_URL = "https://example.com";
export const SITE_NAME = "Example";
export const DEFAULT_OG_IMAGE = "/images/og-default.jpg";
export const absoluteUrl = (path: string) =>
path === "/" ? SITE_URL : `${SITE_URL}${path}`;Metadata, the sitemap, robots rules and structured data then all read from the same place. This is the foundation for the checklist in a practical SEO setup for Next.js.
Validate input at the edge of the system
Route handlers under app/api are your only server entry points for mutations from the browser, so treat their input as untrusted. Check required fields, types and lengths, return clear status codes, and never echo internal error messages to the client.
export async function POST(req: NextRequest) {
const { name, email, message } = await req.json();
if (!name || !email || !message) {
return NextResponse.json({ error: "All fields are required." }, { status: 400 });
}
try {
await connectDB();
const saved = await Contact.create({ name, email, message });
return NextResponse.json({ id: saved._id }, { status: 201 });
} catch {
return NextResponse.json({ error: "Could not save your message." }, { status: 500 });
}
}For anything beyond a simple form, a schema library such as Zod gives you validation and TypeScript types from a single definition.
Configuration and secrets
Read secrets from environment variables on the server only. Anything prefixed with NEXT_PUBLIC_ is inlined into the browser bundle, so reserve that prefix for values that are meant to be public, such as an analytics measurement ID. Keep a committed .env.example listing every variable the app needs, with empty values, so a new machine can be set up without guessing.
A short checklist
- Routes are thin; logic lives in
lib/. - No page queries the database directly.
- Each route has a deliberate rendering mode.
"use client"appears only on leaf components.- Site-wide constants have one home.
- API handlers validate input and hide internals.
- Secrets stay on the server.
None of this is specific to one project. The same shape works for a personal site, an admin dashboard or a course platform. If you want to see the stack applied to real products, the IELTS Mock project is built on it, and the blog index lists the related deep dives.
Related articles
- Server vs Client Components in Next.js: Where to Draw the Boundary
How to decide what runs on the server and what ships to the browser in the Next.js App Router, with patterns for forms, interactivity and passing data across the boundary.
- A Practical SEO Setup for the Next.js App Router
Metadata, canonical URLs, sitemaps, robots rules, structured data and Open Graph images in the Next.js App Router — implemented once, in a way that scales as you add pages.
- One Backend, Two Clients: Architecture for a Next.js Web App and an Expo Mobile App
How to share an API, validation schemas and data-fetching patterns between a Next.js web app and an Expo / React Native app without duplicating business rules.