Next.js uses a file-based routing system where your folder structure directly maps to URL paths. Each folder with a page.tsx file creates a route.
app/
├── page.tsx # Homepage at /
├── about/
│ └── page.tsx # /about
├── contact/
│ └── page.tsx # /contact
├── products/
│ ├── page.tsx # /products
│ └── [id]/
│ └── page.tsx # /products/123, /products/abc
└── api/
└── users/
└── route.ts # API endpoint at /api/usersIn modern web development, your code runs in two distinct environments. Understanding these environments is crucial for building effective Next.js applications.
The Server Environment:
The Client Environment (Browser):
// Runs on server - has full system access
export default async function ServerComponent() {
// ✅ Direct database access
const users = await db.user.findMany()
// ✅ Access to all environment variables
const apiKey = process.env.SECRET_API_KEY
// ✅ File system operations
const config = await readFile('./config.json')
// ✅ Server-only libraries
const encrypted = encrypt(sensitiveData, apiKey)
return <UserList users={users} />
}The server environment is powerful but has important limitations. Understanding these helps you avoid common mistakes that can break your application.
Why relative URLs fail on the server:
What happens when you use relative URLs:
export async function generateMetadata({ params }) {
const { slug } = await params
// ❌ FAILS: Server doesn't know the base URL
const post = await fetch(`/api/posts/${slug}`)
// Error: fetch failed (missing protocol/host)
return { title: post.title }
}Server environment advantages:
The client environment (browser) offers unique capabilities that the server cannot provide. Understanding these capabilities allows you to build rich, interactive user experiences.
Browser-Exclusive Capabilities:
'use client'
import { useState, useEffect } from 'react'
export default function DeviceCapabilities() {
const [location, setLocation] = useState(null)
const [online, setOnline] = useState(true)
useEffect(() => {
// ✅ GPS access
navigator.geolocation.getCurrentPosition(
(position) => setLocation(position.coords),
(error) => console.error('Location access denied')
)
// ✅ Network status monitoring
const handleOnline = () => setOnline(navigator.onLine)
window.addEventListener('online', handleOnline)
window.addEventListener('offline', handleOnline)
// ✅ Device capabilities detection
if ('serviceWorker' in navigator) {
// Register service worker for offline support
}
return () => {
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOnline)
}
}, [])
return (
<div>
<p>Status: {online ? 'Online' : 'Offline'}</p>
{location && (
<p>Location: {location.latitude}, {location.longitude}</p>
)}
</div>
)
}Client Environment Security Constraints:
Performance Considerations:
In Next.js App Router, all components are Server Components by default. This is a major shift from Pages Router where everything was client-side.
Server Components are perfect for:
Client Components handle interactivity:
// No 'use client' - Server Component by default
import { db } from '@/lib/db'
export default async function BlogPosts() {
// Direct database access on server
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10
})
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
)
}Key Differences:
One of the most important decisions in Next.js is choosing between Server and Client Components. This choice affects performance, user experience, and how your code runs.
Understanding where code executes:
Server Components are ideal for:
export default async function ProductPage() {
const products = await getProducts()
return <ProductList products={products} />
}Client Components are needed for:
'use client'
export default function LocationTracker() {
const [location, setLocation] = useState(null)
useEffect(() => {
navigator.geolocation.getCurrentPosition(setLocation)
}, [])
return <div>Location: {location}</div>
}Performance implications:
Server Components can fetch data directly on the server, which is incredibly powerful. But there's a common mistake that can severely impact performance.
Let's see the problem in action:
export default async function PostPage({ params }) {
const { slug } = await params
// ❌ These requests wait for each other!
const post = await getPost(slug)
const comments = await getComments(slug)
const author = await getAuthor(post.authorId)
// If each request takes 200ms:
// Total time: 200ms + 200ms + 200ms = 600ms!
return <PostContent post={post} comments={comments} author={author} />
}Why Promise.all() is recommended:
Server Actions provide a powerful way to handle form submissions and server-side logic without building separate API routes. They seamlessly integrate with forms and provide excellent user experience with progressive enhancement.
Benefits of Server Actions:
Basic Server Action: Create server functions that handle form data and business logic. The 'use server' directive marks functions that run on the server.
'use server'
export async function submitContactForm(formData: FormData) {
const email = formData.get('email') as string
const message = formData.get('message') as string
if (!email || !message) {
return { error: 'Email and message are required' }
}
try {
await sendEmail({ email, message })
return { success: 'Message sent successfully!' }
} catch (error) {
return { error: 'Failed to send message' }
}
}Enhanced experience with React hooks: Use useActionState and useFormStatus to add loading states, error handling, and better UX while maintaining the progressive enhancement benefits.
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { submitContactForm } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send Message'}
</button>
)
}
export default function ContactForm() {
const [state, formAction] = useActionState(submitContactForm, null)
return (
<form action={formAction}>
<input name="email" type="email" required />
<textarea name="message" required />
<SubmitButton />
{state?.error && (
<div className="error">{state.error}</div>
)}
{state?.success && (
<div className="success">{state.success}</div>
)}
</form>
)
}Errors are inevitable in web applications. Next.js provides powerful tools to handle errors gracefully and provide great user experiences even when things go wrong.
Two main approaches to error handling:
Error boundaries create nested fallback levels: Errors "bubble up" to the nearest error.tsx file, allowing you to provide specific error experiences for different parts of your app. All error boundary components must be Client Components.
'use client'
export default function ErrorPage({ error, reset }) {
return (
<div className="error-page">
<h1>Application Error</h1>
<p>Something went wrong with the application</p>
<button onClick={reset}>Try Again</button>
</div>
)
}Not Found handling: When content doesn't exist, use the notFound() function to trigger 404 pages. This provides better UX than generic error messages and proper SEO with 404 status codes.
import { notFound } from 'next/navigation'
export default async function BlogPost({ params }) {
const { slug } = await params
const post = await getBlogPost(slug)
if (!post) {
notFound()
}
return <PostContent post={post} />
}Dynamic routes allow you to create pages that respond to variable URL segments. This is essential for building scalable applications with user-generated content.
Common dynamic route shapes: single segment and catch-all paths. The next slide compares all three bracket patterns, including optional catch-alls.
export default async function BlogPost({ params }) {
const { slug } = await params
const post = await getBlogPost(slug)
return (
<div>
<h1>{post.title}</h1>
<p>Post slug: {slug}</p>
<div>{post.content}</div>
</div>
)
}Catch-all routes example: Perfect for documentation sites and nested categories.
export default async function DocsPage({
params
}: {
params: Promise<{ sections: string[] }>
}) {
const { sections } = await params
const breadcrumbs = sections.map((section, index) => ({
title: section.charAt(0).toUpperCase() + section.slice(1),
href: '/docs/' + sections.slice(0, index + 1).join('/')
}))
return (
<div>
<nav className="breadcrumbs">
<Link href="/docs">Docs</Link>
{breadcrumbs.map((crumb) => (
<span key={crumb.href}>
<span className="separator"> / </span>
<Link href={crumb.href}>{crumb.title}</Link>
</span>
))}
</nav>
<main>
<DocsContent sections={sections} />
</main>
</div>
)
}When to use each pattern:
[slug], [...slug], and [[...slug]].Next.js provides three different dynamic route patterns, each serving specific use cases. Understanding when to use each pattern is crucial for building flexible applications.
export default async function BlogPost({ params }) {
const { slug } = await params
return <article>Post: {slug}</article>
}Pattern Behaviors:
| Pattern | Example URLs | Params Value |
|---|---|---|
/blog/[slug] | /blog/hello-world | { slug: 'hello-world' } |
/docs/[...slug] | /docs/guides/routing | { slug: ['guides', 'routing'] } |
/shop/[[...slug]] | /shop | { slug: [] } |
/shop/[[...slug]] | /shop/electronics/phones | { slug: ['electronics', 'phones'] } |
[...slug] requires at least one segment, while [[...slug]] matches zero or more segments.app/blog/[slug]/page.tsxWhen to use each pattern:
| Pattern | Use Case | Example |
|---|---|---|
[slug] | Single variable segment | Blog posts, user profiles |
[...slug] | Multiple segments, requires at least one | Documentation, nested categories |
[[...slug]] | Multiple segments, including zero | Optional filters, catch-all routes |
Loading states prevent your users from staring at blank screens. Next.js provides powerful tools for creating smooth, progressive loading experiences.
Two approaches to loading states:
// This file creates an automatic loading UI for the /dashboard route
export default function DashboardLoading() {
return (
<div className="dashboard-skeleton">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded mb-6 w-48"></div>
<div className="grid grid-cols-3 gap-6 mb-8">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-24 bg-gray-200 rounded"></div>
))}
</div>
<div className="h-64 bg-gray-200 rounded mb-6"></div>
</div>
</div>
)
}Component-level loading with Suspense: Use Suspense boundaries to show different loading states for different parts of your page. Fast content appears immediately while slow content streams in.
import { Suspense } from 'react'
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<UserWelcome />
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<div>Loading notifications...</div>}>
<NotificationsList />
</Suspense>
</div>
)
}
async function AnalyticsChart() {
const analytics = await getAnalyticsData()
return (
<div className="chart-container">
<h2>Analytics</h2>
<Chart data={analytics} />
</div>
)
}
async function RecentActivity() {
const activity = await getRecentActivity()
return (
<div className="activity-feed">
<h2>Recent Activity</h2>
<ActivityList items={activity} />
</div>
)
}
function UserWelcome() {
return (
<div className="welcome-banner">
<h2>Welcome back!</h2>
<p>Here's what's happening today</p>
</div>
)
}key prop containing the changing param (e.g. key={searchParams.q}) to force the loading state to re-appear.One of Next.js's key features is its navigation system. Understanding how it works is crucial for building fast, modern web applications.
What happens when you click a Next.js Link:
import Link from 'next/link'
export default function Navigation() {
return (
<nav>
{/* Basic client-side navigation */}
<Link href="/about">About Us</Link>
{/* Dynamic route navigation */}
<Link href={`/blog/${post.slug}`}>
{post.title}
</Link>
{/* Navigation with query parameters */}
<Link
href={{
pathname: '/products',
query: { category: 'electronics', sort: 'price' }
}}
>
Electronics
</Link>
</nav>
)
}Programmatic navigation:
'use client'
import { useRouter } from 'next/navigation'
export default function NavigationButton() {
const router = useRouter()
const handleClick = () => {
router.push('/dashboard') // Navigate forward
router.replace('/login') // Replace current page
router.back() // Go back in history
router.refresh() // Refresh current route
}
return <button onClick={handleClick}>Navigate</button>
}| File | Purpose | Type |
|---|---|---|
page.tsx | Route's main content | Server/Client |
layout.tsx | Persistent UI wrapper | Server/Client |
loading.tsx | Loading UI fallback | Server/Client |
error.tsx | Error boundary UI | Client Only |
not-found.tsx | 404 error page | Server/Client |
route.ts | API endpoint handler | Server Only |
Route Groups help you organize your application without affecting the URL structure. They're created using parentheses (folder) syntax.
app/
├── (marketing)/ # Route group - no URL impact
│ ├── layout.tsx # Marketing-specific layout
│ ├── about/page.tsx # /about
│ └── pricing/page.tsx # /pricing
│
├── (auth)/ # Authentication group
│ ├── layout.tsx # Minimal auth layout
│ ├── login/page.tsx # /login
│ └── register/page.tsx # /register
│
└── dashboard/ # Regular folder
└── page.tsx # /dashboardKey Benefits:
Next.js provides powerful error handling through special files that create error boundaries automatically.
Important rule about error.tsx files:
'use client' // ⚠️ REQUIRED: Error boundaries MUST be Client Components
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="error-container">
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}You can create multiple error boundaries for granular error handling:
When an error occurs, Next.js looks for the nearest error.tsx file in the route hierarchy. This allows you to have different error UIs for different sections of your app.
app/
├── error.tsx # Global fallback
├── dashboard/
│ ├── error.tsx # Dashboard-specific errors
│ ├── page.tsx
│ └── settings/
│ ├── error.tsx # Settings-specific errors
│ └── page.tsx
// Error bubbling behavior:
// • Settings page error → settings/error.tsx
// • Other dashboard error → dashboard/error.tsx
// • Any other error → root error.tsxRemember these facts about Next.js file conventions:
Environment variables in Next.js have a special naming convention that determines whether they're accessible on the client-side or server-only.
NEXT_PUBLIC_ are accessible in the browser.Let's see how this works in practice:
# Server-only variables (secure)
DATABASE_URL="postgresql://user:pass@localhost:5432/db"
JWT_SECRET="your-secret-key"
API_SECRET_KEY="sk_live_abc123"
# Client-exposed variables (public)
NEXT_PUBLIC_API_URL="https://api.example.com"
NEXT_PUBLIC_ANALYTICS_ID="GA_TRACKING_ID"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_live_xyz789"Server Components can access ALL environment variables:
// Server Component - runs on server
export default async function ServerComponent() {
const dbUrl = process.env.DATABASE_URL // ✅ Available
const apiUrl = process.env.NEXT_PUBLIC_API_URL // ✅ Available
const secret = process.env.JWT_SECRET // ✅ Available
// Safe to use sensitive data here
const userData = await fetchFromDatabase(dbUrl)
return <div>Server data loaded securely</div>
}Client Components can ONLY access NEXT_PUBLIC_ variables:
'use client'
export default function ClientComponent() {
const dbUrl = process.env.DATABASE_URL // ❌ undefined!
const apiUrl = process.env.NEXT_PUBLIC_API_URL // ✅ Available
const secret = process.env.JWT_SECRET // ❌ undefined!
// Only public variables work here
const handleApiCall = () => {
fetch(apiUrl + '/data') // This works
}
return <button onClick={handleApiCall}>Fetch Data</button>
}server-only package to prevent accidental import of server-only code into Client Components, ensuring sensitive data like API keys remain secure through build-time validation.Proxy (called Middleware prior to Next.js 16) is a powerful Next.js feature that lets you run code before a request is completed. Think of it as a gatekeeper that can inspect, modify, or redirect requests.
Here's a practical example of proxy for authentication:
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
// Authentication check
const token = request.cookies.get('auth-token')
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(
new URL('/login', request.url)
)
}
}
// Add security headers
const response = NextResponse.next()
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
// Configure which paths proxy runs on
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*']
}This proxy example demonstrates:
Common proxy use cases include:
middleware.ts file convention.