Next.js Project Architecture: Complete Folder Structure Guide 2026
Why Project Architecture Matters
A poorly structured Next.js project becomes a nightmare as it grows. Imagine opening a codebase where:
- API calls are scattered across 20 different
page.tsxfiles - Types are duplicated in every component
- Business logic lives inside JSX
- You can't tell if a function is a utility, a hook, or a service
Good architecture is invisible. When done right, you know exactly where to find anything. When done wrong, you waste hours hunting for code.
This module teaches you the production-grade folder structure used by teams at Vercel, Stripe, and Vercel's own dashboard.
The Foundation: Core Directories
app/ — The Heart of Your Application
The app/ directory is where Next.js App Router lives. Every file here has a purpose:
app/
├── layout.tsx ← Root layout (wraps ALL pages)
├── page.tsx ← Home page (/)
├── loading.tsx ← Global loading UI
├── error.tsx ← Global error boundary
├── not-found.tsx ← 404 page
├── robots.ts ← SEO: robots.txt
├── sitemap.ts ← SEO: sitemap.xml
├── globals.css ← Global styles
├── (public)/ ← Route group: marketing pages
├── (protected)/ ← Route group: authenticated pages
└── api/ ← Backend API routes
Key Rule: app/ should contain routing logic only. Keep business logic, data fetching utilities, and UI components out of here.
public/ — Static Assets
Everything here is served at the root URL.
public/
├── images/
│ ├── logo.svg
│ ├── hero-banner.jpg
│ └── team/
│ ├── alice.jpg
│ └── bob.jpg
├── fonts/
│ └── custom-font.woff2
├── favicon.ico
├── robots.txt
└── manifest.json
Access examples:
1// Direct URL 2<img src="/images/logo.svg" alt="Logo" /> 3 4// Or optimized with next/image 5import Image from "next/image"; 6<Image src="/images/hero-banner.jpg" alt="Hero" width={1200} height={600} />
Never put sensitive files here. Everything in
public/is publicly accessible.
Organizing Application Logic
components/ — Reusable UI Pieces
Split components by purpose, not by page:
components/
├── ui/ ← Primitive, reusable UI elements
│ ├── Button.tsx
│ ├── Input.tsx
│ ├── Card.tsx
│ ├── Modal.tsx
│ └── Badge.tsx
│
├── layout/ ← Layout-specific components
│ ├── Navbar.tsx
│ ├── Footer.tsx
│ ├── Sidebar.tsx
│ └── Container.tsx
│
├── courses/ ← Domain-specific components
│ ├── CourseCard.tsx
│ ├── CourseList.tsx
│ ├── CourseFilter.tsx
│ └── CourseProgress.tsx
│
└── forms/ ← Form-related components
├── LoginForm.tsx
├── RegisterForm.tsx
└── ContactForm.tsx
The ui/ subfolder contains your design system primitives — buttons, inputs, cards. These are purely presentational and accept props for customization:
1// components/ui/Button.tsx 2interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { 3 variant?: "primary" | "secondary" | "danger"; 4 size?: "sm" | "md" | "lg"; 5 isLoading?: boolean; 6} 7 8export function Button({ 9 variant = "primary", 10 size = "md", 11 isLoading = false, 12 children, 13 ...props 14}: ButtonProps) { 15 const baseStyles = "rounded font-medium transition-colors"; 16 const variants = { 17 primary: "bg-blue-600 text-white hover:bg-blue-700", 18 secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300", 19 danger: "bg-red-600 text-white hover:bg-red-700", 20 }; 21 const sizes = { 22 sm: "px-3 py-1.5 text-sm", 23 md: "px-4 py-2 text-base", 24 lg: "px-6 py-3 text-lg", 25 }; 26 27 return ( 28 <button 29 className={`${baseStyles} ${variants[variant]} ${sizes[size]}`} 30 disabled={isLoading} 31 {...props} 32 > 33 {isLoading ? "Loading..." : children} 34 </button> 35 ); 36}
lib/ — Utilities, Configurations & Helpers
The lib/ folder is your toolbox. It holds everything that isn't a component:
lib/
├── api/ ← API client & request helpers
│ ├── client.ts ← Axios/fetch instance
│ └── requests.ts ← Typed API functions
│
├── auth/ ← Authentication logic
│ ├── session.ts ← Session management
│ └── permissions.ts ← Role-based access
│
├── utils/ ← General utilities
│ ├── cn.ts ← Tailwind class merger (clsx + tailwind-merge)
│ ├── formatDate.ts ← Date formatting
│ └── formatCurrency.ts
│
├── validations/ ← Zod schemas
│ ├── userSchema.ts
│ └── courseSchema.ts
│
└── db/ ← Database connection (Prisma/Drizzle)
└── index.ts
Example: API Client (lib/api/client.ts)
1// Centralized API client — never call fetch directly in components 2const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL; 3 4interface ApiResponse<T> { 5 data: T; 6 message?: string; 7} 8 9export async function apiClient<T>( 10 endpoint: string, 11 options?: RequestInit 12): Promise<T> { 13 const response = await fetch(`${API_BASE_URL}${endpoint}`, { 14 headers: { 15 "Content-Type": "application/json", 16 ...options?.headers, 17 }, 18 ...options, 19 }); 20 21 if (!response.ok) { 22 throw new Error(`API Error: ${response.status}`); 23 } 24 25 return response.json(); 26}
Example: Validation Schema (lib/validations/courseSchema.ts)
1import { z } from "zod"; 2 3export const courseSchema = z.object({ 4 title: z.string().min(3, "Title must be at least 3 characters"), 5 description: z.string().min(10), 6 price: z.number().positive("Price must be positive"), 7 category: z.enum(["frontend", "backend", "devops"]), 8 isPublished: z.boolean().default(false), 9}); 10 11export type CourseInput = z.infer<typeof courseSchema>;
types/ — Shared TypeScript Types
Centralize types to avoid duplication and inconsistency:
types/
├── index.ts ← Barrel export
├── user.ts ← User-related types
├── course.ts ← Course-related types
├── api.ts ← API response types
└── next-auth.d.ts ← NextAuth type extensions
Example:
1// types/course.ts 2export interface Course { 3 id: string; 4 title: string; 5 description: string; 6 price: number; 7 category: "frontend" | "backend" | "devops"; 8 thumbnail: string; 9 instructor: UserSummary; 10 lessons: Lesson[]; 11 createdAt: Date; 12 updatedAt: Date; 13} 14 15export interface UserSummary { 16 id: string; 17 name: string; 18 avatar: string; 19} 20 21export interface Lesson { 22 id: string; 23 title: string; 24 duration: number; // in minutes 25 videoUrl: string; 26 isFree: boolean; 27}
Barrel export (types/index.ts):
1export * from "./user"; 2export * from "./course"; 3export * from "./api";
Now import everything cleanly:
1import { Course, UserSummary } from "@/types";
hooks/ — Custom React Hooks
Extract reusable logic into custom hooks:
hooks/
├── useAuth.ts ← Authentication state
├── useCourses.ts ← Course data fetching
├── useLocalStorage.ts ← Local storage wrapper
├── useDebounce.ts ← Debounce utility
└── useMediaQuery.ts ← Responsive breakpoints
Example: Data Fetching Hook
1// hooks/useCourses.ts 2import useSWR from "swr"; 3import { apiClient } from "@/lib/api/client"; 4import { Course } from "@/types"; 5 6export function useCourses(category?: string) { 7 const { data, error, isLoading, mutate } = useSWR<Course[]>( 8 category ? `/courses?category=${category}` : "/courses", 9 apiClient 10 ); 11 12 return { 13 courses: data ?? [], 14 isLoading, 15 isError: !!error, 16 refresh: mutate, 17 }; 18}
Usage in a component:
1"use client"; 2 3import { useCourses } from "@/hooks/useCourses"; 4 5export default function CourseList() { 6 const { courses, isLoading, isError } = useCourses("frontend"); 7 8 if (isLoading) return <Skeleton />; 9 if (isError) return <ErrorMessage />; 10 11 return ( 12 <div className="grid gap-4"> 13 {courses.map(course => ( 14 <CourseCard key={course.id} course={course} /> 15 ))} 16 </div> 17 ); 18}
services/ — Business Logic Layer
Services encapsulate business operations that span multiple API calls or complex data transformations:
services/
├── courseService.ts ← Course CRUD operations
├── paymentService.ts ← Stripe/PayPal integration
├── emailService.ts ← Email sending logic
└── uploadService.ts ← File upload handling
Example: Course Service
1// services/courseService.ts 2import { apiClient } from "@/lib/api/client"; 3import { Course, CourseInput } from "@/types"; 4 5export const courseService = { 6 async getAll(category?: string): Promise<Course[]> { 7 const query = category ? `?category=${category}` : ""; 8 return apiClient(`/courses${query}`); 9 }, 10 11 async getById(id: string): Promise<Course> { 12 return apiClient(`/courses/${id}`); 13 }, 14 15 async create(data: CourseInput): Promise<Course> { 16 return apiClient("/courses", { 17 method: "POST", 18 body: JSON.stringify(data), 19 }); 20 }, 21 22 async update(id: string, data: Partial<CourseInput>): Promise<Course> { 23 return apiClient(`/courses/${id}`, { 24 method: "PATCH", 25 body: JSON.stringify(data), 26 }); 27 }, 28 29 async delete(id: string): Promise<void> { 30 return apiClient(`/courses/${id}`, { 31 method: "DELETE", 32 }); 33 }, 34 35 async enroll(courseId: string, userId: string): Promise<void> { 36 // Complex business logic: check prerequisites, process payment, enroll 37 const course = await this.getById(courseId); 38 if (!course) throw new Error("Course not found"); 39 40 return apiClient(`/courses/${courseId}/enroll`, { 41 method: "POST", 42 body: JSON.stringify({ userId }), 43 }); 44 }, 45};
Why not put this in
lib/api/?lib/api/is for infrastructure (the HTTP client).services/is for business operations (what you do with that client).
actions/ — Server Actions (Next.js 14+)
Server Actions are functions that run on the server, called directly from components:
actions/
├── auth.ts ← Login, logout, register
├── course.ts ← Course mutations
├── enrollment.ts ← Enrollment operations
└── payment.ts ← Payment processing
Example: Course Actions
1// actions/course.ts 2"use server"; 3 4import { revalidatePath } from "next/cache"; 5import { courseService } from "@/services/courseService"; 6import { courseSchema } from "@/lib/validations/courseSchema"; 7 8export async function createCourse(formData: FormData) { 9 // 1. Validate input 10 const rawData = Object.fromEntries(formData); 11 const validated = courseSchema.safeParse(rawData); 12 13 if (!validated.success) { 14 return { error: validated.error.flatten().fieldErrors }; 15 } 16 17 // 2. Call service 18 try { 19 const course = await courseService.create(validated.data); 20 21 // 3. Revalidate cache 22 revalidatePath("/courses"); 23 24 return { success: true, data: course }; 25 } catch (error) { 26 return { error: "Failed to create course" }; 27 } 28}
Usage in a form:
1// components/forms/CreateCourseForm.tsx 2import { createCourse } from "@/actions/course"; 3 4export default function CreateCourseForm() { 5 return ( 6 <form action={createCourse}> 7 <input name="title" placeholder="Course title" /> 8 <textarea name="description" placeholder="Description" /> 9 <input name="price" type="number" placeholder="Price" /> 10 <button type="submit">Create Course</button> 11 </form> 12 ); 13}
Route Groups: Organizing Pages Logically
Route groups (folders with parentheses) let you organize routes without affecting the URL:
app/
├── (public)/ ← Group: no URL prefix
│ ├── page.tsx ← /
│ ├── about/
│ │ └── page.tsx ← /about
│ └── courses/
│ └── page.tsx ← /courses
│
├── (protected)/ ← Group: requires auth
│ ├── dashboard/
│ │ └── page.tsx ← /dashboard
│ └── profile/
│ └── page.tsx ← /profile
│
└── api/ ← API routes
└── courses/
└── route.ts ← /api/courses
Benefits:
- Apply different layouts to different sections
- Keep related pages together
- No URL pollution from folder names
Different layouts per group:
1// app/(public)/layout.tsx 2export default function PublicLayout({ children }) { 3 return ( 4 <> 5 <MarketingNav /> 6 {children} 7 <Footer /> 8 </> 9 ); 10} 11 12// app/(protected)/layout.tsx 13import { requireAuth } from "@/lib/auth/session"; 14 15export default async function ProtectedLayout({ children }) { 16 await requireAuth(); // Redirects if not logged in 17 18 return ( 19 <div className="flex"> 20 <Sidebar /> 21 <main className="flex-1">{children}</main> 22 </div> 23 ); 24}
Special Files in the App Router
Next.js recognizes these special filenames automatically:
| File | Purpose | Example |
|---|---|---|
layout.tsx | Shared UI wrapper | Navigation, footer, providers |
page.tsx | Route page content | The actual page |
loading.tsx | Loading UI | Skeleton screens |
error.tsx | Error boundary | Error messages |
not-found.tsx | 404 page | "Page not found" |
route.ts | API endpoint | Backend logic |
template.tsx | Re-mounting layout | Forms that need fresh state |
default.tsx | Parallel route fallback | Default parallel content |
Real example of a complete route segment:
app/blog/
├── layout.tsx ← Blog layout with sidebar
├── page.tsx ← Blog listing
├── loading.tsx ← Skeleton while loading
├── error.tsx ← Error boundary
├── not-found.tsx ← "No posts found"
└── [slug]/
├── page.tsx ← Individual post
├── loading.tsx ← Post skeleton
└── not-found.tsx ← "Post not found"
Configuration Files Explained
next.config.ts
1import type { NextConfig } from "next"; 2 3const nextConfig: NextConfig = { 4 // Image optimization 5 images: { 6 remotePatterns: [ 7 { protocol: "https", hostname: "cdn.example.com" }, 8 { protocol: "https", hostname: "images.unsplash.com" }, 9 ], 10 }, 11 12 // Redirects 13 async redirects() { 14 return [ 15 { source: "/old-path", destination: "/new-path", permanent: true }, 16 ]; 17 }, 18 19 // Rewrites (proxying) 20 async rewrites() { 21 return [ 22 { source: "/api/:path*", destination: "https://api.example.com/:path*" }, 23 ]; 24 }, 25 26 // Headers (security) 27 async headers() { 28 return [ 29 { 30 source: "/(.*)", 31 headers: [ 32 { key: "X-Frame-Options", value: "DENY" }, 33 { key: "X-Content-Type-Options", value: "nosniff" }, 34 ], 35 }, 36 ]; 37 }, 38 39 // Experimental features 40 experimental: { 41 ppr: true, // Partial Prerendering 42 }, 43}; 44 45export default nextConfig;
Environment Variables
Next.js has strict rules for environment variables:
.env.local ← Local development (never commit)
.env.development ← Development environment
.env.production ← Production environment
.env ← Shared defaults (can commit non-secrets)
Naming convention:
| Prefix | Accessible In | Use For |
|---|---|---|
NEXT_PUBLIC_ | Server + Client | Public API URLs, feature flags |
| (no prefix) | Server only | Database URLs, API keys, secrets |
Example .env.local:
1# Server-only (secret) 2DATABASE_URL="postgresql://user:pass@localhost:5432/db" 3STRIPE_SECRET_KEY="sk_test_..." 4JWT_SECRET="super-secret-key" 5 6# Public (available in browser) 7NEXT_PUBLIC_API_URL="https://api.example.com" 8NEXT_PUBLIC_APP_NAME="Tech3Space"
Accessing in code:
1// Server-side (anywhere) 2const dbUrl = process.env.DATABASE_URL; 3 4// Client-side (only NEXT_PUBLIC_ vars) 5const apiUrl = process.env.NEXT_PUBLIC_API_URL;
Critical: Never prefix secrets with
NEXT_PUBLIC_. They will be exposed to the browser!
The Complete Production Structure
Here's the full architecture used by production teams:
tech3space/
├── src/ ← Optional src directory
│ ├── app/
│ │ ├── (public)/
│ │ │ ├── page.tsx
│ │ │ ├── about/
│ │ │ │ └── page.tsx
│ │ │ └── courses/
│ │ │ ├── page.tsx
│ │ │ └── [slug]/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx
│ │ │ └── not-found.tsx
│ │ │
│ │ ├── (protected)/
│ │ │ ├── dashboard/
│ │ │ │ ├── page.tsx
│ │ │ │ └── layout.tsx
│ │ │ └── profile/
│ │ │ └── page.tsx
│ │ │
│ │ ├── api/
│ │ │ ├── auth/
│ │ │ │ └── [...nextauth]/
│ │ │ │ └── route.ts
│ │ │ └── courses/
│ │ │ └── route.ts
│ │ │
│ │ ├── layout.tsx
│ │ ├── loading.tsx
│ │ ├── error.tsx
│ │ ├── not-found.tsx
│ │ ├── globals.css
│ │ ├── robots.ts
│ │ └── sitemap.ts
│ │
│ ├── components/
│ │ ├── ui/
│ │ │ ├── Button.tsx
│ │ │ ├── Input.tsx
│ │ │ ├── Card.tsx
│ │ │ ├── Modal.tsx
│ │ │ └── index.ts
│ │ │
│ │ ├── layout/
│ │ │ ├── Navbar.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ └── Container.tsx
│ │ │
│ │ ├── courses/
│ │ │ ├── CourseCard.tsx
│ │ │ ├── CourseList.tsx
│ │ │ ├── CourseFilter.tsx
│ │ │ └── CourseProgress.tsx
│ │ │
│ │ └── forms/
│ │ ├── LoginForm.tsx
│ │ └── CourseForm.tsx
│ │
│ ├── lib/
│ │ ├── api/
│ │ │ ├── client.ts
│ │ │ └── requests.ts
│ │ │
│ │ ├── auth/
│ │ │ ├── session.ts
│ │ │ └── permissions.ts
│ │ │
│ │ ├── utils/
│ │ │ ├── cn.ts
│ │ │ ├── formatDate.ts
│ │ │ └── formatCurrency.ts
│ │ │
│ │ ├── validations/
│ │ │ ├── userSchema.ts
│ │ │ └── courseSchema.ts
│ │ │
│ │ └── db/
│ │ └── index.ts
│ │
│ ├── hooks/
│ │ ├── useAuth.ts
│ │ ├── useCourses.ts
│ │ ├── useLocalStorage.ts
│ │ └── useDebounce.ts
│ │
│ ├── types/
│ │ ├── index.ts
│ │ ├── user.ts
│ │ ├── course.ts
│ │ └── api.ts
│ │
│ ├── services/
│ │ ├── courseService.ts
│ │ ├── paymentService.ts
│ │ └── emailService.ts
│ │
│ └── actions/
│ ├── auth.ts
│ ├── course.ts
│ └── enrollment.ts
│
├── public/
│ ├── images/
│ │ ├── logo.svg
│ │ └── courses/
│ ├── favicon.ico
│ └── robots.txt
│
├── next.config.ts
├── tsconfig.json
├── tailwind.config.ts
├── postcss.config.mjs
├── eslint.config.mjs
├── .env.local
├── .env.example
└── package.json
Where Does Logic Belong? The Golden Rules
| Type of Code | Belongs In | Why |
|---|---|---|
| Page structure & routing | app/ | Next.js convention |
| Reusable UI elements | components/ui/ | Design system primitives |
| Page-specific sections | components/[domain]/ | Domain organization |
| HTTP requests & API config | lib/api/ | Infrastructure layer |
| Business operations | services/ | Encapsulated logic |
| Data mutations (server) | actions/ | Server Actions |
| Reusable stateful logic | hooks/ | React conventions |
| Type definitions | types/ | Single source of truth |
| Validation schemas | lib/validations/ | Input safety |
| Auth & security | lib/auth/ | Centralized security |
Anti-Patterns to Avoid
❌ Bad: Everything in page.tsx
1// app/courses/page.tsx — DON'T DO THIS 2"use client"; 3 4import { useState, useEffect } from "react"; 5 6export default function CoursesPage() { 7 const [courses, setCourses] = useState([]); 8 const [loading, setLoading] = useState(true); 9 const [error, setError] = useState(null); 10 11 useEffect(() => { 12 fetch("https://api.example.com/courses") 13 .then(res => res.json()) 14 .then(data => { 15 setCourses(data); 16 setLoading(false); 17 }) 18 .catch(err => { 19 setError(err); 20 setLoading(false); 21 }); 22 }, []); 23 24 if (loading) return <div>Loading...</div>; 25 if (error) return <div>Error!</div>; 26 27 return ( 28 <div> 29 {courses.map(course => ( 30 <div key={course.id}> 31 <h2>{course.title}</h2> 32 <p>{course.description}</p> 33 <button onClick={() => { 34 fetch(`/api/enroll`, { 35 method: "POST", 36 body: JSON.stringify({ courseId: course.id }) 37 }); 38 }}> 39 Enroll 40 </button> 41 </div> 42 ))} 43 </div> 44 ); 45}
Problems:
- No type safety
- API URL hardcoded
- Loading/error logic duplicated
- Business logic mixed with JSX
- Can't reuse data fetching
- No validation
✅ Good: Separated Concerns
1// app/courses/page.tsx — CLEAN VERSION 2import { Suspense } from "react"; 3import { CourseList } from "@/components/courses/CourseList"; 4import { CourseListSkeleton } from "@/components/courses/CourseListSkeleton"; 5 6export default function CoursesPage() { 7 return ( 8 <main className="max-w-7xl mx-auto px-4 py-8"> 9 <h1 className="text-3xl font-bold mb-6">All Courses</h1> 10 <Suspense fallback={<CourseListSkeleton />}> 11 <CourseList /> 12 </Suspense> 13 </main> 14 ); 15}
1// components/courses/CourseList.tsx 2import { courseService } from "@/services/courseService"; 3import { CourseCard } from "./CourseCard"; 4 5export async function CourseList() { 6 const courses = await courseService.getAll(); 7 8 return ( 9 <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> 10 {courses.map(course => ( 11 <CourseCard key={course.id} course={course} /> 12 ))} 13 </div> 14 ); 15}
1// components/courses/CourseCard.tsx 2import Image from "next/image"; 3import Link from "next/link"; 4import { Course } from "@/types"; 5 6interface CourseCardProps { 7 course: Course; 8} 9 10export function CourseCard({ course }: CourseCardProps) { 11 return ( 12 <article className="border rounded-lg overflow-hidden hover:shadow-lg transition"> 13 <Image 14 src={course.thumbnail} 15 alt={course.title} 16 width={400} 17 height={225} 18 className="w-full h-48 object-cover" 19 /> 20 <div className="p-4"> 21 <h3 className="font-semibold text-lg">{course.title}</h3> 22 <p className="text-gray-600 text-sm mt-1">{course.description}</p> 23 <Link 24 href={`/courses/${course.id}`} 25 className="text-blue-600 mt-3 inline-block" 26 > 27 Learn More → 28 </Link> 29 </div> 30 </article> 31 ); 32}
Import Aliases: Clean Imports
Configure @/ alias in tsconfig.json:
1{ 2 "compilerOptions": { 3 "baseUrl": ".", 4 "paths": { 5 "@/*": ["./src/*"] 6 } 7 } 8}
Now import cleanly from anywhere:
1import { Button } from "@/components/ui/Button"; 2import { courseService } from "@/services/courseService"; 3import { useAuth } from "@/hooks/useAuth"; 4import { Course } from "@/types";
No more ../../../../../../components/Button hell!
Summary Checklist
-
app/contains only routing & page structure -
components/ui/holds design system primitives -
components/[domain]/holds page sections -
lib/api/has the HTTP client only -
services/encapsulates business logic -
actions/handles server-side mutations -
hooks/extracts reusable stateful logic -
types/is the single source of truth -
public/only has static assets - Environment variables use correct prefixes
- Import aliases are configured
What's Next?
In the upcoming modules, we'll build on this architecture:
- Module 4: Routing & Navigation — Deep dive into App Router patterns
- Module 5: Data Fetching — Server Components, caching, revalidation
- Module 6: Authentication — Protecting routes with the
(protected)group
Your mission: Reorganize your Tech3Space project using this architecture. Move your components into the right folders, create a services/ layer, and set up proper type definitions. A well-structured project is a maintainable project!