A Next.js app with 5 pages can be structured however you want. A Next.js app with 50 pages, 30 components, 10 server actions, and multiple data sources needs conventions, or it becomes a maze.
Here's the structure I use across every project.
src/
├── app/ # Routes only, no business logic
│ ├── (dashboard)/ # Route groups for layouts
│ │ ├── layout.tsx
│ │ ├── plants/
│ │ ├── reports/
│ │ └── settings/
│ ├── (marketing)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── pricing/
│ └── api/ # API routes (webhooks, external APIs only)
├── components/
│ ├── ui/ # shadcn/ui primitives (never edit directly)
│ ├── forms/ # Form components with validation
│ ├── tables/ # Data table components
│ └── [feature].tsx # Feature-specific components
├── lib/
│ ├── db.ts # Prisma client singleton
│ ├── auth.ts # Auth configuration
│ ├── utils.ts # Shared utilities
│ └── validations/ # Zod schemas
├── data/
│ └── site-config.ts # Static configuration
├── actions/ # Server actions grouped by domain
│ ├── plants.ts
│ ├── reports.ts
│ └── users.ts
└── types/ # Shared TypeScript typesPage files should be thin. They fetch data and render components. No business logic, no complex state management.
// app/(dashboard)/plants/page.tsx
export default async function PlantsPage() {
const plants = await getPlants();
return <PlantList plants={plants} />;
}Don't put server actions in the app directory next to the page that uses them. Group them by domain so multiple pages can share them.
The (dashboard) and (marketing) groups share different layouts, sidebar nav vs. landing page nav, without affecting the URL structure.
No exceptions. A file named plant-card.tsx exports PlantCard. If a component grows too large, split it, don't nest components in the same file.
Define your data shape once with Zod. Derive TypeScript types from it. Use the same schema for form validation, API input validation, and database writes.
// lib/validations/plant.ts
export const createPlantSchema = z.object({
name: z.string().min(1),
capacity: z.number().positive(),
location: z.string(),
});
export type CreatePlantInput = z.infer<typeof createPlantSchema>;I've used this exact layout for apps ranging from 5 pages (GoSolarIndex.in) to 50+ pages (Reflux forecasting platform). It works because the conventions are simple and the boundaries are clear.