Next.js makes it easy to build React applications, but scaling from prototype to production requires thoughtful architecture. Here's how we structure Next.js applications for long-term success.
Project Structure
A well-organized codebase prevents technical debt:
/app # Next.js 14+ App Router
/(routes) # Route groups for layouts
/dashboard
/admin
/api # API routes
/components
/ui # Reusable UI components
/features # Feature-specific components
/layouts # Layout components
/lib # Utility functions, helpers
/db # Database clients
/auth # Authentication logic
/api # API clients
/hooks # Custom React hooks
/types # TypeScript definitions
/public # Static assets
/styles # Global styles
Component Architecture
The Component Hierarchy
Organize components by reusability:
1. UI Components (Atomic)
// components/ui/button.tsx
export function Button({ children, variant, ...props }: ButtonProps) {
return (
<button className={cn(buttonVariants({ variant }))} {...props}>
{children}
</button>
)
}
2. Feature Components (Composed)
// components/features/user-profile.tsx
export function UserProfile({ user }: UserProfileProps) {
return (
<Card>
<CardHeader>
<Avatar src={user.avatar} />
<Heading>{user.name}</Heading>
</CardHeader>
<CardContent>
{/* Feature-specific logic */}
</CardContent>
</Card>
)
}
3. Page Components (Orchestrators)
// app/dashboard/page.tsx
export default async function DashboardPage() {
const user = await getUser()
return (
<DashboardLayout>
<UserProfile user={user} />
<ActivityFeed userId={user.id} />
</DashboardLayout>
)
}
Data Fetching Strategy
Next.js 14 Server Components change the game:
Server Components (Default)
Fetch data directly on the server:
// app/products/page.tsx
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // Cache for 1 hour
})
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return <ProductGrid products={products} />
}
Benefits:
- No client-side loading states
- Better SEO
- Reduced client bundle size
- Direct database access
Client Components
Use sparingly for interactivity:
'use client'
import { useState } from 'react'
export function SearchBar() {
const [query, setQuery] = useState('')
// Client-side interactivity
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
)
}
When to Use Each
Server Components:
- Static content
- Data fetching
- Authentication checks
- SEO-critical pages
Client Components:
- Form inputs
- User interactions
- Browser APIs
- Real-time updates
State Management
Keep it simple:
1. URL State (Best for Filtering/Pagination)
// app/products/page.tsx
export default function ProductsPage({
searchParams
}: {
searchParams: { category?: string, page?: string }
}) {
const category = searchParams.category ?? 'all'
const page = parseInt(searchParams.page ?? '1')
// Fetch based on URL params
}
2. React Context (App-Wide State)
// lib/auth-context.tsx
'use client'
const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
return (
<AuthContext.Provider value={{ user, setUser }}>
{children}
</AuthContext.Provider>
)
}
3. Server Actions (Form Submissions)
// app/actions.ts
'use server'
export async function createProduct(formData: FormData) {
const name = formData.get('name')
await db.products.create({
data: { name }
})
revalidatePath('/products')
redirect('/products')
}
API Route Design
Structure API routes for maintainability:
// app/api/products/route.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'
const productSchema = z.object({
name: z.string().min(1),
price: z.number().positive()
})
export async function POST(request: Request) {
try {
const body = await request.json()
const data = productSchema.parse(body)
const product = await db.products.create({ data })
return NextResponse.json(product, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
Performance Optimization
1. Image Optimization
Always use Next.js Image component:
import Image from 'next/image'
<Image
src="/product.jpg"
alt="Product"
width={800}
height={600}
priority // Above the fold
placeholder="blur" // Smooth loading
/>
2. Code Splitting
Dynamic imports for heavy components:
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('@/components/chart'), {
loading: () => <Skeleton />,
ssr: false // Client-only component
})
3. Caching Strategy
Leverage Next.js caching:
// Static data (cached indefinitely)
fetch('https://api.example.com/config')
// Revalidate every hour
fetch('https://api.example.com/products', {
next: { revalidate: 3600 }
})
// Never cache
fetch('https://api.example.com/user', {
cache: 'no-store'
})
Error Handling
Graceful error handling at every level:
Error Boundaries
// app/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={reset}>Try again</button>
</div>
)
}
Loading States
// app/loading.tsx
export default function Loading() {
return <Skeleton />
}
Security Best Practices
1. Environment Variables
# .env.local
DATABASE_URL=postgres://...
API_SECRET=your-secret-key
# .env (committed)
NEXT_PUBLIC_API_URL=https://api.example.com
2. Authentication
Use established libraries:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
export const config = {
matcher: '/dashboard/:path*'
}
3. Input Validation
Always validate with Zod or similar:
import { z } from 'zod'
const schema = z.object({
email: z.string().email(),
password: z.string().min(8)
})
// Validate before processing
const result = schema.safeParse(data)
if (!result.success) {
// Handle validation errors
}
Testing Strategy
Unit Tests (Vitest)
import { render, screen } from '@testing-library/react'
import { Button } from './button'
test('renders button', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
Integration Tests (Playwright)
import { test, expect } from '@playwright/test'
test('user can log in', async ({ page }) => {
await page.goto('/login')
await page.fill('[name="email"]', 'user@example.com')
await page.fill('[name="password"]', 'password')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/dashboard')
})
Deployment Checklist
Before going to production:
- Enable strict TypeScript mode
- Add comprehensive error boundaries
- Implement analytics (Vercel Analytics, Plausible)
- Set up monitoring (Sentry, LogRocket)
- Configure CSP headers
- Enable rate limiting on API routes
- Optimize images and fonts
- Run Lighthouse audit
- Test on multiple devices/browsers
- Set up CI/CD pipeline
Takeaway
Production-ready Next.js applications require more than just working code. Proper architecture, performance optimization, and security practices separate hobby projects from enterprise applications.
Start with solid foundations, and your application will scale smoothly as your needs grow.
Need help building production Next.js applications? Our Full Stack Development Services team specializes in React and Next.js. Schedule a consultation to discuss your project.