Next.js 15 Complete Guide: Building Modern Web Applications
Master Next.js 15 with the latest features including React 19, Server Components, App Router, and performance optimizations. Complete guide with practical examples and real-world applications.
Next.js 15 Complete Guide: Building Modern Web Applications
Next.js 15 represents a significant evolution in React-based web development, introducing groundbreaking features like React 19 integration, enhanced Server Components, and revolutionary performance optimizations. This comprehensive guide covers everything from basic setup to advanced production deployments.
Why Next.js 15?
Next.js 15 brings unprecedented capabilities to modern web development:
Key Advantages
- React 19 Integration: Latest React features and performance improvements
- Enhanced Server Components: Better performance and SEO
- Improved App Router: More intuitive routing and layouts
- Better Performance: Faster builds and runtime performance
- Enhanced Developer Experience: Better debugging and development tools
- Production Ready: Built-in optimizations for production deployments
New Features in Next.js 15
- React 19 Support: Latest React features and concurrent rendering
- Enhanced Server Actions: Better server-side functionality
- Improved Caching: Better caching strategies and performance
- Enhanced TypeScript: Better type safety and developer experience
- Better Error Handling: Improved error boundaries and debugging
Getting Started with Next.js 15
Installation and Setup
# Create a new Next.js 15 project
npx create-next-app@latest my-nextjs-app
# Choose your preferred options:
# - TypeScript: Yes
# - ESLint: Yes
# - Tailwind CSS: Yes
# - App Router: Yes
# - src/ directory: Yes
# - Import alias: Yes
cd my-nextjs-app
npm run devProject Structure
my-nextjs-app/
āāā src/
ā   āāā app/
ā   ā   āāā layout.tsx
ā   ā   āāā page.tsx
ā   ā   āāā globals.css
ā   ā   āāā loading.tsx
ā   āāā components/
ā   ā   āāā ui/
ā   ā   āāā layout/
ā   āāā lib/
ā   āāā types/
āāā public/
āāā package.json
āāā next.config.jsApp Router Deep Dive
Layout System
// src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
  title: 'My Next.js 15 App',
  description: 'Built with Next.js 15 and React 19',
}
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <nav className="bg-white shadow-sm">
          <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
            <div className="flex justify-between h-16">
              <div className="flex items-center">
                <h1 className="text-xl font-bold">My App</h1>
              </div>
            </div>
          </div>
        </nav>
        <main className="min-h-screen">
          {children}
        </main>
      </body>
    </html>
  )
}Dynamic Routes and Parameters
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
interface BlogPostProps {
  params: {
    slug: string
  }
}
async function getBlogPost(slug: string) {
  // Fetch blog post data
  const res = await fetch(`https://api.example.com/posts/${slug}`)
  
  if (!res.ok) {
    return null
  }
  
  return res.json()
}
export default async function BlogPost({ params }: BlogPostProps) {
  const post = await getBlogPost(params.slug)
  
  if (!post) {
    notFound()
  }
  
  return (
    <article className="max-w-4xl mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
      <div className="prose prose-lg max-w-none">
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </div>
    </article>
  )
}
// Generate static params for static generation
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(res => res.json())
  
  return posts.map((post: any) => ({
    slug: post.slug,
  }))
}Server Components and Client Components
Server Components
// src/components/ServerUserProfile.tsx
import { getUser } from '@/lib/auth'
interface UserProfileProps {
  userId: string
}
export default async function ServerUserProfile({ userId }: UserProfileProps) {
  // This runs on the server
  const user = await getUser(userId)
  
  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h2 className="text-2xl font-bold mb-4">User Profile</h2>
      <div className="space-y-2">
        <p><strong>Name:</strong> {user.name}</p>
        <p><strong>Email:</strong> {user.email}</p>
        <p><strong>Joined:</strong> {new Date(user.createdAt).toLocaleDateString()}</p>
      </div>
    </div>
  )
}Client Components
// src/components/ClientCounter.tsx
'use client'
import { useState } from 'react'
export default function ClientCounter() {
  const [count, setCount] = useState(0)
  
  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h3 className="text-xl font-bold mb-4">Counter</h3>
      <div className="flex items-center space-x-4">
        <button
          onClick={() => setCount(count - 1)}
          className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
        >
          -
        </button>
        <span className="text-2xl font-bold">{count}</span>
        <button
          onClick={() => setCount(count + 1)}
          className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
        >
          +
        </button>
      </div>
    </div>
  )
}Data Fetching and Caching
Server-Side Data Fetching
// src/app/dashboard/page.tsx
import { Suspense } from 'react'
async function getDashboardData() {
  // This runs on the server and is cached
  const [users, posts, analytics] = await Promise.all([
    fetch('https://api.example.com/users', { 
      next: { revalidate: 3600 } // Cache for 1 hour
    }).then(res => res.json()),
    fetch('https://api.example.com/posts', {
      next: { revalidate: 1800 } // Cache for 30 minutes
    }).then(res => res.json()),
    fetch('https://api.example.com/analytics', {
      next: { revalidate: 300 } // Cache for 5 minutes
    }).then(res => res.json())
  ])
  
  return { users, posts, analytics }
}
export default async function Dashboard() {
  const { users, posts, analytics } = await getDashboardData()
  
  return (
    <div className="max-w-7xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Dashboard</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <Suspense fallback={<div>Loading users...</div>}>
          <UsersList users={users} />
        </Suspense>
        
        <Suspense fallback={<div>Loading posts...</div>}>
          <PostsList posts={posts} />
        </Suspense>
        
        <Suspense fallback={<div>Loading analytics...</div>}>
          <AnalyticsWidget analytics={analytics} />
        </Suspense>
      </div>
    </div>
  )
}Client-Side Data Fetching
// src/components/ClientDataFetch.tsx
'use client'
import { useState, useEffect } from 'react'
interface Post {
  id: number
  title: string
  body: string
}
export default function ClientDataFetch() {
  const [posts, setPosts] = useState<Post[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  
  useEffect(() => {
    async function fetchPosts() {
      try {
        setLoading(true)
        const response = await fetch('/api/posts')
        
        if (!response.ok) {
          throw new Error('Failed to fetch posts')
        }
        
        const data = await response.json()
        setPosts(data)
      } catch (err) {
        setError(err instanceof Error ? err.message : 'An error occurred')
      } finally {
        setLoading(false)
      }
    }
    
    fetchPosts()
  }, [])
  
  if (loading) return <div>Loading posts...</div>
  if (error) return <div>Error: {error}</div>
  
  return (
    <div className="space-y-4">
      {posts.map(post => (
        <div key={post.id} className="bg-white p-4 rounded-lg shadow">
          <h3 className="text-xl font-bold mb-2">{post.title}</h3>
          <p className="text-gray-600">{post.body}</p>
        </div>
      ))}
    </div>
  )
}API Routes and Server Actions
API Routes
// src/app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
  try {
    const posts = await fetch('https://jsonplaceholder.typicode.com/posts')
    const data = await posts.json()
    
    return NextResponse.json(data)
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to fetch posts' },
      { status: 500 }
    )
  }
}
export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    
    // Validate the request body
    if (!body.title || !body.content) {
      return NextResponse.json(
        { error: 'Title and content are required' },
        { status: 400 }
      )
    }
    
    // Save the post (in a real app, you'd save to a database)
    const newPost = {
      id: Date.now(),
      title: body.title,
      content: body.content,
      createdAt: new Date().toISOString()
    }
    
    return NextResponse.json(newPost, { status: 201 })
  } catch (error) {
    return NextResponse.json(
      { error: 'Failed to create post' },
      { status: 500 }
    )
  }
}Server Actions
// src/lib/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string
  
  // Validate input
  if (!title || !content) {
    throw new Error('Title and content are required')
  }
  
  // Save to database (example)
  const newPost = {
    id: Date.now(),
    title,
    content,
    createdAt: new Date().toISOString()
  }
  
  // Revalidate the posts page
  revalidatePath('/posts')
  
  // Redirect to the new post
  redirect(`/posts/${newPost.id}`)
}
export async function deletePost(postId: string) {
  // Delete from database
  await fetch(`/api/posts/${postId}`, {
    method: 'DELETE'
  })
  
  // Revalidate the posts page
  revalidatePath('/posts')
}Using Server Actions in Forms
// src/components/CreatePostForm.tsx
import { createPost } from '@/lib/actions'
export default function CreatePostForm() {
  return (
    <form action={createPost} className="space-y-4">
      <div>
        <label htmlFor="title" className="block text-sm font-medium text-gray-700">
          Title
        </label>
        <input
          type="text"
          id="title"
          name="title"
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
        />
      </div>
      
      <div>
        <label htmlFor="content" className="block text-sm font-medium text-gray-700">
          Content
        </label>
        <textarea
          id="content"
          name="content"
          rows={4}
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
        />
      </div>
      
      <button
        type="submit"
        className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
      >
        Create Post
      </button>
    </form>
  )
}Performance Optimization
Image Optimization
// src/components/OptimizedImage.tsx
import Image from 'next/image'
interface OptimizedImageProps {
  src: string
  alt: string
  width: number
  height: number
  priority?: boolean
}
export default function OptimizedImage({ 
  src, 
  alt, 
  width, 
  height, 
  priority = false 
}: OptimizedImageProps) {
  return (
    <Image
      src={src}
      alt={alt}
      width={width}
      height={height}
      priority={priority}
      className="rounded-lg shadow-lg"
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  )
}Dynamic Imports and Code Splitting
// src/components/LazyComponent.tsx
import dynamic from 'next/dynamic'
// Lazy load a heavy component
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  loading: () => <div>Loading heavy component...</div>,
  ssr: false // Disable SSR for this component
})
export default function LazyComponent() {
  return (
    <div>
      <h2>Lazy Loaded Component</h2>
      <HeavyComponent />
    </div>
  )
}Caching Strategies
// src/lib/cache.ts
import { unstable_cache } from 'next/cache'
export const getCachedData = unstable_cache(
  async (id: string) => {
    // Expensive operation
    const data = await fetch(`https://api.example.com/data/${id}`)
    return data.json()
  },
  ['data'], // Cache key
  {
    revalidate: 3600, // Cache for 1 hour
    tags: ['data'] // Cache tags for invalidation
  }
)Deployment and Production
Vercel Deployment
# Install Vercel CLI
npm i -g vercel
# Deploy to Vercel
vercel
# Set environment variables
vercel env add DATABASE_URL
vercel env add API_SECRETDocker Deployment
# Dockerfile
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
  if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
  elif [ -f package-lock.json ]; then npm ci; \
  elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
  else echo "Lockfile not found." && exit 1; \
  fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
# set hostname to localhost
ENV HOSTNAME "0.0.0.0"
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD ["node", "server.js"]Environment Configuration
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    serverComponentsExternalPackages: ['@prisma/client']
  },
  images: {
    domains: ['example.com', 'cdn.example.com'],
    formats: ['image/webp', 'image/avif']
  },
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Credentials', value: 'true' },
          { key: 'Access-Control-Allow-Origin', value: '*' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT' },
          { key: 'Access-Control-Allow-Headers', value: 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version' }
        ]
      }
    ]
  }
}
module.exports = nextConfigBest Practices and Tips
1. Component Organization
- Use Server Components by default
- Only use Client Components when necessary
- Keep components small and focused
- Use TypeScript for better type safety
2. Performance Optimization
- Use Next.js Image component for images
- Implement proper caching strategies
- Use dynamic imports for code splitting
- Optimize bundle size with webpack analysis
3. SEO and Metadata
- Use the metadata API for SEO
- Implement proper structured data
- Use dynamic metadata for pages
- Optimize for Core Web Vitals
4. Error Handling
- Implement error boundaries
- Use proper error pages
- Handle loading states
- Implement retry mechanisms
Conclusion
Next.js 15 with React 19 represents the cutting edge of modern web development, offering powerful features for building fast, scalable, and maintainable applications. By following this guide and implementing the best practices outlined, you can create production-ready applications that leverage the full power of the Next.js ecosystem.
The key to success with Next.js 15 lies in understanding the App Router, mastering Server and Client Components, and implementing proper performance optimizations. With these tools and techniques, you can build applications that are both developer-friendly and user-optimized.