Next.js has been my go-to React framework for the past few years, and with the release of Next.js 15, the team at Vercel has once again raised the bar for what a modern web framework should look like. Whether you're building a personal portfolio, a SaaS product, or a large-scale enterprise application, Next.js 15 brings a host of improvements that make development faster, more intuitive, and significantly more performant.
In this post, I'll walk you through everything that's new, share real code examples, and give you my honest take on what matters most for production applications.
Why Next.js 15 Matters
The web development landscape moves fast, but Next.js has consistently stayed ahead of the curve. Version 15 isn't just an incremental update - it's a refinement of the architectural decisions introduced in Next.js 13 and 14, now battle-tested and production-ready. The App Router is more stable, React Server Components are the default, Turbopack is finally stable, and new features like Partial Prerendering blur the line between static and dynamic content.
If you've been hesitant to migrate from the Pages Router or from an older version, now is the time. Let me show you why.
The Improved App Router
The App Router, introduced in Next.js 13, has matured significantly. In Next.js 15, it's the recommended way to build applications. Here's what makes it powerful:
Layouts
Layouts let you share UI between routes while preserving state. Instead of re-rendering the entire page on navigation, only the content inside the layout changes.
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>
<main>{children}</main>
<footer>© 2025 My App</footer>
</body>
</html>
);
}The beauty of this approach is that your navigation and footer persist across route transitions without a full page reload. You can also nest layouts — for example, having a dashboard layout inside your root layout.
Loading States
Next.js 15 makes it trivial to add loading states with the loading.tsx convention:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
);
}This file automatically wraps your page in a React Suspense boundary. When the page is loading data, users see the skeleton instead of a blank screen.
Error Boundaries
Similarly, error.tsx gives you graceful error handling per route segment:
// app/blog/error.tsxexport default function Error({ error, reset, }: { error: Error; reset: () => void; }) { return ( <div className="text-center py-10"> <h2>Something went wrong!</h2> <p>{error.message}</p> <button onClick={() => reset()}>Try again</button> </div> ); } ```
Notice the "use client" directive — error boundaries must be client components because they use React's useEffect and state management under the hood.
Parallel Routes
One of the most underused features is parallel routes, which let you render multiple pages simultaneously in the same layout:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div className="grid grid-cols-2 gap-4">
<div>{children}</div>
<div>{analytics}</div>
<div>{team}</div>
</div>
);
}Each slot (@analytics, @team) can have its own loading and error states, making complex dashboards much easier to build.
React Server Components as the Default
In Next.js 15, every component is a Server Component by default. This is a fundamental shift in how we think about React. Server Components run on the server, which means:
- ●Zero JavaScript sent to the client for server-rendered content
- ●Direct database access without API routes
- ●Smaller bundle sizes because server-only code never reaches the browser
// app/blog/page.tsx — this is a Server Component by defaultexport default async function BlogPage() { const posts = await db.post.findMany({ orderBy: { createdAt: "desc" }, take: 10, });
return ( <div> <h1>Latest Posts</h1> {posts.map((post) => ( <article key={post.id}> <h2>{post.title}</h2> <p>{post.excerpt}</p> </article> ))} </div> ); } ```
When to Use `"use client"`
You only need client components when you need:
- Event handlers (onClick, onChange, etc.)
- React hooks (useState, useEffect, useRef)
- Browser-only APIs (localStorage, window, navigator)
import { useState } from "react";
export default function LikeButton({ postId }: { postId: string }) { const [liked, setLiked] = useState(false);
return ( <button onClick={() => setLiked(!liked)}> {liked ? "❤️" : "🤍"} Like </button> ); } ```
My rule of thumb: Start with Server Components. Only add "use client" when you absolutely need interactivity. This keeps your bundles small and your app fast.
Turbopack Stable — The Speed Revolution
Turbopack, the Rust-based successor to Webpack, is now stable in Next.js 15 for the dev server. The performance improvements are staggering:
| Metric | Webpack | Turbopack | Improvement |
|---|---|---|---|
| Cold start | ~3.5s | ~1.2s | 65% faster |
| Hot Module Replacement | ~500ms | ~50ms | 10x faster |
| Route compilation | ~800ms | ~200ms | 4x faster |
To enable Turbopack, simply use the --turbopack flag:
npx next dev --turbopackOr update your package.json:
{
"scripts": {
"dev": "next dev --turbopack"
}
}I've been using Turbopack on a project with 200+ routes, and the difference is night and day. HMR is essentially instant — you save a file, and the change appears in the browser before you can even switch tabs. If you're still on Webpack, migrating is seamless because Turbopack is designed to be a drop-in replacement.
Server Actions — Simplified Form Handling
Server Actions eliminate the need for API routes for many common tasks like form submissions. They're functions that run on the server but can be called directly from client components:
// app/contact/page.tsx
export default function ContactPage() {
async function submitForm(formData: FormData) {const name = formData.get("name") as string; const email = formData.get("email") as string; const message = formData.get("message") as string;
await db.contact.create({ data: { name, email, message }, });
// Revalidate the page or redirect revalidatePath("/contact"); }
return ( <form action={submitForm}> <input name="name" placeholder="Your name" required /> <input name="email" type="email" placeholder="Email" required /> <textarea name="message" placeholder="Message" required /> <button type="submit">Send Message</button> </form> ); } ```
What I love about Server Actions is that they work without JavaScript. The form submits as a standard HTML form, which means progressive enhancement out of the box. When JavaScript is available, the submission happens via fetch with no page reload.
Partial Prerendering (PPR)
This is perhaps the most exciting feature in Next.js 15. Partial Prerendering lets you combine static and dynamic content in a single route. The static shell is served instantly from the CDN, while dynamic parts stream in.
// app/page.tsx
import { Suspense } from "react";
import { StaticHero } from "@/components/StaticHero";export default function HomePage() { return ( <div> {/* This renders at build time — static */} <StaticHero />
{/* This streams in dynamically */} <Suspense fallback={<FeedSkeleton />}> <DynamicFeed /> </Suspense> </div> ); } ```
To enable PPR, add this to your next.config.ts:
const nextConfig = {
experimental: {
ppr: true,
},export default nextConfig; ```
PPR gives you the best of both worlds — the speed of static sites with the freshness of dynamic content. No more choosing between SSG and SSR.
Improved Image and Font Optimization
The <Image> component in Next.js 15 is smarter than ever. It automatically handles lazy loading, responsive sizing, and format optimization (serving WebP or AVIF where supported):
export default function Profile() { return ( <Image src="/profile.jpg" alt="Deneth Kavishka" width={400} height={400} priority // loads this image immediately className="rounded-full" /> ); } ```
For fonts, next/font eliminates layout shift by hosting fonts locally with zero configuration:
const inter = Inter({ subsets: ["latin"] }); const firaCode = Fira_Code({ subsets: ["latin"] });
export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html className={inter.className}> <body>{children}</body> </html> ); } ```
This approach eliminates Flash of Unstyled Text (FOUT) and external network requests for fonts — a subtle but impactful performance win.
Step-by-Step: Getting Started
Ready to try Next.js 15? Here's how to set up a new project in under five minutes:
1. Create a new project:
npx create-next-app@latest my-appYou'll be prompted with options. I recommend these choices: - TypeScript: Yes - ESLint: Yes - Tailwind CSS: Yes - App Router: Yes - Turbopack: Yes
2. Navigate and start the dev server:
cd my-app
npm run dev3. Create your first page:
// app/page.tsx
export default function Home() {
return (
<div className="min-h-screen flex items-center justify-center">
<h1 className="text-4xl font-bold">
Welcome to Next.js 15!
</h1>
</div>
);
}4. Add a dynamic route:
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {return <h1>Post: {slug}</h1>; } ```
Note that in Next.js 15, params is now a Promise — a breaking change from v14 that you should be aware of when migrating.
Performance: Next.js 15 vs Previous Versions
Here's a real-world comparison from migrating one of my projects:
| Metric | Next.js 13 | Next.js 14 | Next.js 15 |
|---|---|---|---|
| Lighthouse Performance | 82 | 89 | 96 |
| First Contentful Paint | 1.8s | 1.2s | 0.6s |
| Time to Interactive | 3.2s | 2.1s | 1.4s |
| JS Bundle Size | 245KB | 198KB | 142KB |
| Build Time | 48s | 35s | 22s |
The improvements come from better tree-shaking, React Server Components reducing client JavaScript, and Turbopack's optimized bundling.
Best Practices for Production
After shipping several Next.js 15 apps to production, here are my top recommendations:
- Default to Server Components — Only use
"use client"when you need interactivity. This alone can reduce your bundle size by 30-50%.
- Use
loading.tsxeverywhere — Every route segment should have a loading state. Users should never see a blank screen.
- Leverage caching strategically — Use
revalidatefor ISR,cache: 'no-store'for real-time data, andunstable_cachefor granular caching.
- Colocate data fetching — Fetch data where it's needed, not at the top level. Next.js automatically deduplicates fetch requests.
- Use
generateStaticParams— For dynamic routes with known paths, pre-render them at build time for instant loading.
- Monitor your bundles — Use
@next/bundle-analyzerto identify and eliminate unnecessary client-side JavaScript.
- Implement proper error boundaries — Add
error.tsxfiles to catch and handle errors gracefully in every route segment.
Conclusion
Next.js 15 is, in my opinion, the most complete and polished version of the framework to date. The combination of stable Turbopack, mature App Router, React Server Components, and Partial Prerendering creates a development experience that's both powerful and enjoyable.
If you're starting a new project in 2025, I can't recommend Next.js 15 highly enough. And if you're on an older version, the migration path is well-documented and worth the effort — the performance gains alone justify the investment.
The web platform is evolving, and Next.js 15 keeps you right at the forefront. Give it a try, and I promise you'll never want to go back.
Happy coding!


Comments
0 comments
Leave a Comment