How to Structure a Large Next.js App Router Project Without Messy Route Logic
- Author
- Vishal Maurya
- Published on
- Reading time
- 4 min read
Overview
A small App Router project is easy to read. A year later, pages contain hundreds of lines of data fetching, validation, and JSX, components are scattered between app/ and a giant components/ folder, and nobody is sure where to put new code. Next.js does not enforce a project structure, so you need conventions.
This post describes a practical layout. It is a starting point, not the only valid one.
1. Keep app/ for Routing
Only files with special names, such as page.tsx, layout.tsx, route.ts, and loading.tsx, become routes. Other files placed in a route folder are safe to colocate; they are not publicly routable. That allows colocation, but it also invites clutter.
A useful rule: app/ describes URLs and composition, while feature code lives elsewhere.
src/
app/
(marketing)/
page.tsx
pricing/page.tsx
(app)/
layout.tsx
dashboard/page.tsx
invoices/
page.tsx
[id]/page.tsx
api/
webhooks/stripe/route.ts
features/
invoices/
components/
actions.ts
queries.ts
schema.ts
index.ts
lib/
db.ts
auth.ts
components/ui/
2. Use Route Groups for Layout Boundaries
Folders in parentheses, such as (marketing) and (app), group routes without affecting the URL. Their main use is giving different sections different layouts, for example a public marketing layout and an authenticated app shell with a sidebar.
Be aware that if you create multiple root layouts through route groups, navigating between them triggers a full page load. Also avoid two groups resolving to the same URL path, which produces a conflict.
3. Make Pages Thin
A page should read parameters, call a query, and compose components. Business logic belongs in the feature folder.
// app/(app)/invoices/[id]/page.tsx
import { notFound } from 'next/navigation';
import { getInvoice } from '@/features/invoices/queries';
import { InvoiceDetail } from '@/features/invoices/components/invoice-detail';
export default async function InvoicePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const invoice = await getInvoice(id);
if (!invoice) notFound();
return <InvoiceDetail invoice={invoice} />;
}
In Next.js 14, params is a plain object rather than a Promise, so adjust to your version.
4. Group Code by Feature, Not by Type
A top-level components/, hooks/, and utils/ split makes every change touch four folders. Group by domain instead. Each feature owns its queries, actions, validation schema, and components, and exposes a small public surface:
// features/invoices/index.ts
export { getInvoice, listInvoices } from './queries';
export { createInvoice } from './actions';
export type { Invoice } from './schema';
Keep truly shared, domain-free pieces such as buttons and form primitives in components/ui.
5. Separate Queries, Actions, and Components
- queries.ts: read functions called from Server Components. Mark with
import 'server-only'if they touch secrets or the database. - actions.ts: Server Actions with
"use server". Validate input and check authorization inside each one; treat them as public endpoints. - components/: server components by default; add
"use client"only to interactive leaves.
This separation makes it obvious which code can run in the browser and prevents accidental leaks.
6. Use Private Folders for Colocation
Prefixing a folder with an underscore, such as _components, opts it out of routing and signals that it is private to that route segment. This is useful for pieces used by only one page:
app/(app)/invoices/
page.tsx
_components/invoice-filters.tsx
If a second route needs the component, promote it to the feature folder.
7. Centralize Cross-Cutting Concerns
Place authentication checks in a shared helper in lib/auth.ts and call it from layouts or data functions. Do not rely on a layout alone to protect data, because layouts do not re-render on every navigation. Enforce authorization close to the data access.
Configure path aliases in tsconfig.json so imports such as @/features/invoices stay stable when files move.
8. Enforce the Rules
Conventions decay without tooling. Use ESLint import restrictions or a boundary plugin to stop features importing each other's internals, and add a short CONTRIBUTING note describing where new code goes.
Conclusion
Keep app/ thin, group by feature, validate at the edges, and make server and client responsibilities visible in file names. The result is easier onboarding and fewer places for route logic to hide.
If you are untangling a large Next.js codebase or planning a restructure, I can help design the layout and migrate it incrementally. Contact me.