Getting Started with Next.js 15: The Complete 2025 Developer Guide
Getting Started with Next.js 15: The Complete 2025 Developer Guide
Next.js 15 represents the most significant evolution of the React framework since its inception. Released October 2024 and now stable as of December 2025, it brings React 19 support, a production-ready Turbopack, revolutionary Partial Prerendering, and fundamental changes to how we think about building web applications.
I've been using Next.js since version 9, and this version feels like the framework finally achieving what it always promised: the best developer experience with the best user experience. Let me show you everything you need to know.
What's Actually New in Next.js 15
Not just marketing bullet points - here's what genuinely matters:
The Big Changes (December 2025 Status)
| Feature | Status | What It Means |
|---|---|---|
| React 19 Support | ✅ Stable | Actions, use() hook, improved hydration |
| Turbopack | ✅ Stable (dev) | 700% faster cold starts, 96% faster HMR |
| Partial Prerendering | ✅ Stable | Static + dynamic in same request |
| Async Request APIs | ✅ Required | cookies(), headers() are now async |
| Caching Changes | ✅ Default off | fetch(), Route Handlers no longer cached by default |
| Enhanced Security | ✅ Stable | Server Actions protection, improved CSRF handling |
Breaking Changes You Need to Know
cookies(),headers(),params,searchParamsare now async - this will break existing code- Default caching is OFF - explicit opt-in required
- Minimum React version is 19 - no backwards compatibility
- Node.js 18.18+ required - older versions won't work
Installation and Setup (December 2025)
Creating a New Project
# Recommended: Use create-next-app with all defaults
npx create-next-app@latest my-project
# Or with specific options
npx create-next-app@latest my-project --typescript --tailwind --eslint --app --src-dir --use-pnpm
The CLI will ask you:
✔ Would you like to use TypeScript? Yes
✔ Would you like to use ESLint? Yes
✔ Would you like to use Tailwind CSS? Yes
✔ Would you like your code inside a `src/` directory? Yes
✔ Would you like to use App Router? (recommended) Yes
✔ Would you like to use Turbopack for `next dev`? Yes
✔ Would you like to customize the import alias? No
#OPINION: Always say yes to TypeScript and the src/ directory. You'll thank yourself in 6 months when the project grows.
Upgrading from Next.js 14
# Automatic upgrade with codemods
npx @next/codemod@latest upgrade latest
# Or manual upgrade
npm install next@latest react@latest react-dom@latest
The codemod handles:
- Async request API transformations
- Deprecated API removals
- Import path updates
- Configuration migrations
Project Structure Deep Dive
my-project/
├── src/
│ ├── app/ # App Router (routes)
│ │ ├── (auth)/ # Route groups (no URL impact)
│ │ │ ├── login/
│ │ │ │ └── page.tsx
│ │ │ └── register/
│ │ │ └── page.tsx
│ │ ├── api/ # API routes
│ │ │ └── webhooks/
│ │ │ └── route.ts
│ │ ├── blog/
│ │ │ ├── [slug]/ # Dynamic route
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx # Root layout (required)
│ │ ├── page.tsx # Home page
│ │ ├── error.tsx # Error boundary
│ │ ├── loading.tsx # Loading UI
│ │ ├── not-found.tsx # 404 page
│ │ └── globals.css
│ ├── components/ # Reusable components
│ │ ├── ui/ # shadcn/ui or similar
│ │ └── features/ # Feature-specific
│ ├── lib/ # Utilities, configs
│ ├── hooks/ # Custom React hooks
│ └── types/ # TypeScript types
├── public/ # Static assets
├── next.config.ts # Next.js configuration
├── tailwind.config.ts # Tailwind CSS
├── tsconfig.json # TypeScript
└── package.json
Understanding the App Router
Every folder in app/ can have these special files:
| File | Purpose |
|---|---|
page.tsx | Unique UI for this route (makes it publicly accessible) |
layout.tsx | Shared layout wrapping children |
template.tsx | Like layout but re-mounts on navigation |
loading.tsx | Loading UI (Suspense boundary) |
error.tsx | Error boundary (client-side) |
not-found.tsx | 404 UI for this segment |
route.ts | API endpoint (GET, POST, etc.) |
default.tsx | Parallel route fallback |
Server Components vs Client Components
This is the mental model shift that trips up most developers.
Server Components (Default)
// app/blog/[slug]/page.tsx
// This runs ONLY on the server - no JS sent to client
import { db } from '@/lib/db';
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
// Note: params is now a Promise in Next.js 15!
const { slug } = await params;
// Direct database access - no API needed
const post = await db.post.findUnique({ where: { slug } });
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
Benefits:
- Zero JavaScript for static content
- Direct database/filesystem access
- Better SEO (complete HTML)
- Faster initial page load
Client Components
// components/like-button.tsx
'use client'; // This directive is required
import { useState, useTransition } from 'react';
import { likePost } from '@/app/actions';
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
const [isPending, startTransition] = useTransition();
const handleLike = () => {
startTransition(async () => {
const newLikes = await likePost(postId);
setLikes(newLikes);
});
};
return (
<button onClick={handleLike} disabled={isPending}>
{isPending ? 'Liking...' : `❤️ ${likes}`}
</button>
);
}
Use Client Components when you need:
- useState, useEffect, useReducer
- Browser APIs (localStorage, geolocation)
- Event handlers (onClick, onChange)
- Third-party libraries that use hooks
The Composition Pattern
// app/blog/[slug]/page.tsx (Server Component)
import { LikeButton } from '@/components/like-button';
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
{/* Client component embedded in Server component */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
);
}
Server Actions (The Game Changer)
Server Actions eliminate the need for API routes in most cases. They're functions that run on the server but can be called from the client.
Defining Server Actions
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
});
export async function createPost(formData: FormData) {
// Validate input
const validatedFields = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!validatedFields.success) {
return { error: validatedFields.error.flatten().fieldErrors };
}
const { title, content } = validatedFields.data;
// Insert into database
const post = await db.post.create({
data: { title, content }
});
// Revalidate cache and redirect
revalidatePath('/blog');
redirect(`/blog/${post.slug}`);
}
Using Server Actions in Forms
// app/blog/new/page.tsx
import { createPost } from '@/app/actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write your post..." required />
<button type="submit">Publish</button>
</form>
);
}
Progressive Enhancement
Forms with Server Actions work without JavaScript:
- Form submits as normal POST request
- Server processes and redirects
- If JS loads, enhances to prevent full page reload
#OPINION: Server Actions are the biggest quality-of-life improvement in Next.js. They make full-stack development feel seamless.
The New Async Request APIs
This is the breaking change that will affect most codebases.
Before (Next.js 14)
// ❌ This no longer works
import { cookies } from 'next/headers';
export default function Page() {
const cookieStore = cookies();
const theme = cookieStore.get('theme');
// ...
}
After (Next.js 15)
// ✅ Must await the APIs
import { cookies, headers } from 'next/headers';
export default async function Page() {
const cookieStore = await cookies();
const headersList = await headers();
const theme = cookieStore.get('theme');
const userAgent = headersList.get('user-agent');
// ...
}
Params and SearchParams
// ❌ Before
type Props = { params: { slug: string } };
// ✅ After - params is a Promise
type Props = { params: Promise<{ slug: string }> };
export default async function Page({ params, searchParams }: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ sort?: string }>;
}) {
const { slug } = await params;
const { sort } = await searchParams;
// ...
}
Why This Change?
Vercel explains this enables:
- Better static analysis
- Improved prerendering
- Foundation for Partial Prerendering
Caching: The New Defaults
Major shift: Next.js 15 defaults to NO caching. You must explicitly opt-in.
fetch() Caching
// ❌ Before: cached by default
fetch('https://api.example.com/data');
// ✅ After: NOT cached by default
fetch('https://api.example.com/data');
// ✅ Explicit caching
fetch('https://api.example.com/data', { cache: 'force-cache' });
// ✅ With revalidation
fetch('https://api.example.com/data', { next: { revalidate: 3600 } });
Route Handlers
// app/api/data/route.ts
// ❌ Before: GET was cached by default
// ✅ After: NOT cached by default
export async function GET() {
const data = await fetchData();
return Response.json(data);
}
// To cache, export config
export const dynamic = 'force-static';
// Or with revalidation
export const revalidate = 3600;
When to Cache What
| Content Type | Cache Strategy |
|---|---|
| Static pages | force-static |
| API data that changes rarely | revalidate: 3600 (1 hour) |
| User-specific data | No cache (default) |
| Real-time data | No cache (default) |
| Product listings | revalidate: 60 |
#OPINION: The new defaults are correct. Implicit caching caused too many "why isn't my data updating?" bugs.
Turbopack: The Speed Revolution
Turbopack is now stable for next dev as of October 2024.
Enabling Turbopack
# In development
next dev --turbo
# Or in package.json
"scripts": {
"dev": "next dev --turbo"
}
Performance Benchmarks (Real-World)
From the Vercel documentation:
| Metric | Webpack | Turbopack | Improvement |
|---|---|---|---|
| Cold start | 11.2s | 1.6s | 700% faster |
| Route compile | 800ms | 35ms | 96% faster |
| HMR (small change) | 450ms | 15ms | 97% faster |
| Memory usage | 1.8GB | 600MB | 66% less |
Current Limitations (December 2025)
- Production builds still use Webpack
- Some plugins not yet compatible
- Custom Webpack configs may need adjustment
#OPINION: Turbopack for dev is a game-changer. The HMR feels instant even on large codebases.
Partial Prerendering (PPR)
The future of Next.js rendering. Combines static and dynamic in the same request.
How It Works
// app/product/[id]/page.tsx
import { Suspense } from 'react';
import { ProductDetails } from '@/components/product-details';
import { ProductReviews } from '@/components/product-reviews';
import { AddToCartButton } from '@/components/add-to-cart';
// Static shell renders at build time
// Dynamic parts stream in at request time
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const product = await getProduct(id);
return (
<main>
{/* Static - prerendered */}
<ProductDetails product={product} />
{/* Dynamic - streamed */}
<Suspense fallback={<div>Loading reviews...</div>}>
<ProductReviews productId={id} />
</Suspense>
{/* Dynamic - user-specific */}
<Suspense fallback={<div>Loading cart...</div>}>
<AddToCartButton productId={id} />
</Suspense>
</main>
);
}
Enabling PPR
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;
Per-Route Opt-In
// app/product/[id]/page.tsx
export const experimental_ppr = true;
TypeScript Configuration (2025 Best Practices)
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Data Fetching Patterns
Direct Database Access (Recommended)
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db;
// app/blog/page.tsx
import { db } from '@/lib/db';
export default async function BlogPage() {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
External API Fetching
// lib/api.ts
export async function getProducts() {
const res = await fetch('https://api.store.com/products', {
next: { revalidate: 60 }, // Cache for 60 seconds
});
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
Parallel Data Fetching
export default async function DashboardPage() {
// Fetch in parallel - don't await each sequentially!
const [user, orders, notifications] = await Promise.all([
getUser(),
getOrders(),
getNotifications(),
]);
return (
<Dashboard user={user} orders={orders} notifications={notifications} />
);
}
Metadata and SEO
Static Metadata
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: {
default: 'My Website',
template: '%s | My Website',
},
description: 'Building the future of the web',
metadataBase: new URL('https://mywebsite.com'),
openGraph: {
type: 'website',
siteName: 'My Website',
images: ['/og-image.png'],
},
twitter: {
card: 'summary_large_image',
creator: '@myhandle',
},
robots: {
index: true,
follow: true,
},
};
Dynamic Metadata
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
type: 'article',
publishedTime: post.createdAt,
authors: [post.author.name],
},
};
}
Deployment to Vercel
Environment Variables
# .env.local (development)
DATABASE_URL="postgresql://..."
NEXTAUTH_SECRET="your-secret"
# Vercel dashboard (production)
# Add these in Settings > Environment Variables
next.config.ts for Production
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.unsplash.com',
},
],
},
experimental: {
ppr: true,
},
logging: {
fetches: {
fullUrl: true,
},
},
};
export default nextConfig;
Build and Deploy
# Local build test
npm run build
npm start
# Deploy to Vercel
vercel
# Or connect to GitHub for automatic deploys
Common Mistakes to Avoid
1. Forgetting async on Request APIs
// ❌ Wrong
const cookieStore = cookies(); // Error!
// ✅ Correct
const cookieStore = await cookies();
2. Using Client-Side Hooks in Server Components
// ❌ Wrong - server component can't use useState
export default function Page() {
const [count, setCount] = useState(0); // Error!
}
// ✅ Correct - mark as client component
'use client';
export default function Counter() {
const [count, setCount] = useState(0);
}
3. Not Awaiting Params
// ❌ Wrong
export default function Page({ params }) {
const slug = params.slug; // params is a Promise!
}
// ✅ Correct
export default async function Page({ params }) {
const { slug } = await params;
}
4. Expecting Cached fetch by Default
// ❌ Expecting cache (won't work in Next.js 15)
const data = await fetch('/api/data');
// ✅ Explicit cache
const data = await fetch('/api/data', { cache: 'force-cache' });
Performance Checklist
- Use Server Components for static content
- Implement Suspense boundaries for streaming
- Enable Turbopack for development
- Configure proper caching strategies
- Optimize images with
next/image - Use dynamic imports for heavy components
- Implement proper error boundaries
- Add loading states for better UX
- Use React 19's
use()hook for async resources - Consider Partial Prerendering for mixed content
Resources and Next Steps
Official Documentation
- Next.js Docs - The primary reference
- Next.js Learn - Official interactive course
- Vercel Blog - Latest updates and best practices
Community Resources
- Next.js GitHub Discussions
- Next.js Discord
- r/nextjs - Reddit community
Recommended Libraries (December 2025)
| Category | Library | Why |
|---|---|---|
| Database | Prisma / Drizzle | Type-safe ORM |
| Auth | NextAuth.js v5 | Built for App Router |
| Forms | React Hook Form + Zod | Type-safe validation |
| UI | shadcn/ui | Composable components |
| State | Zustand | Lightweight, React 19 ready |
| API | tRPC | End-to-end type safety |
Bibliography and Sources
- Next.js 15 Release Notes - Vercel, October 2024 - https://nextjs.org/blog/next-15
- React 19 Release - React Team, December 2024 - https://react.dev/blog/2024/12/05/react-19
- Turbopack Announcement - Vercel, October 2024 - https://turbo.build/pack/docs
- Partial Prerendering RFC - Next.js GitHub - https://github.com/vercel/next.js/discussions/58443
- App Router Documentation - Next.js - https://nextjs.org/docs/app
- Server Components RFC - React Working Group - https://github.com/reactwg/server-components
Last updated: December 2025. This guide is maintained to reflect the latest stable releases and best practices.