Next.js 15 is the most popular React framework — now with Turbopack stable, React 19 support, partial prerendering, and the new after() API. Here's everything you get for free.
What's New in Next.js 15
- Turbopack stable — 76% faster local dev, 96% faster HMR
-
React 19 support — Server Components, Actions,
use()hook - Partial Prerendering (PPR) — static shell + dynamic content
-
after()API — run code after response is sent -
Enhanced
fetchcaching — no more aggressive caching surprises -
next/form— client-side navigation for forms
Server Components (Default in App Router)
// app/users/page.tsx — this is a Server Component by default
async function UsersPage() {
// Direct database access — no API route needed!
const users = await db.user.findMany({
select: { id: true, name: true, email: true },
});
return (
<div>
<h1>Users ({users.length})</h1>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} — {user.email}
</li>
))}
</ul>
</div>
);
}
export default UsersPage;
No useEffect. No loading states. No API routes. Server Components fetch data directly.
Server Actions (Forms Without API Routes)
// app/contacts/page.tsx
async function createContact(formData: FormData) {
"use server";
const name = formData.get("name") as string;
const email = formData.get("email") as string;
await db.contact.create({ data: { name, email } });
revalidatePath("/contacts");
}
export default async function ContactsPage() {
const contacts = await db.contact.findMany();
return (
<div>
<form action={createContact}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Add Contact</button>
</form>
<ul>
{contacts.map((c) => (
<li key={c.id}>{c.name}</li>
))}
</ul>
</div>
);
}
The after() API (New in 15!)
import { after } from "next/server";
export async function POST(request: Request) {
const data = await request.json();
// Process the request
const result = await processOrder(data);
// This runs AFTER the response is sent to the user
after(async () => {
await sendConfirmationEmail(data.email);
await updateAnalytics("order_created");
await notifySlack(`New order: ${result.id}`);
});
// User gets fast response, background work happens after
return Response.json(result);
}
Partial Prerendering (PPR)
// next.config.js
export default {
experimental: {
ppr: true,
},
};
// app/dashboard/page.tsx
import { Suspense } from "react";
// Static shell (prerendered at build time)
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1> {/* Static */}
<nav>...</nav> {/* Static */}
{/* Dynamic — streams in after static shell loads */}
<Suspense fallback={<div>Loading stats...</div>}>
<DynamicStats />
</Suspense>
<Suspense fallback={<div>Loading feed...</div>}>
<ActivityFeed />
</Suspense>
</div>
);
}
// This component is dynamic — fetches fresh data each request
async function DynamicStats() {
const stats = await getRealtimeStats(); // No cache
return <div>Revenue: ${stats.revenue}</div>;
}
Static parts load instantly (from CDN). Dynamic parts stream in. Best of both worlds.
Middleware
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Auth check
const token = request.cookies.get("session");
if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
// A/B testing
const response = NextResponse.next();
if (!request.cookies.get("variant")) {
response.cookies.set("variant", Math.random() > 0.5 ? "A" : "B");
}
// Geolocation
const country = request.geo?.country || "US";
response.headers.set("x-country", country);
return response;
}
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};
Route Handlers (API Routes in App Router)
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = Number(searchParams.get("page")) || 1;
const users = await db.user.findMany({
skip: (page - 1) * 10,
take: 10,
});
return NextResponse.json({ users, page });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
}
Parallel Routes
app/
@analytics/
page.tsx
@feed/
page.tsx
layout.tsx
// app/layout.tsx
export default function Layout({
children,
analytics,
feed,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
feed: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3">
<main className="col-span-2">{children}</main>
<aside>
{analytics}
{feed}
</aside>
</div>
);
}
Both @analytics and @feed load in parallel — no waterfalls.
Next.js 15 vs Remix vs Astro
| Feature | Next.js 15 | Remix | Astro |
|---|---|---|---|
| RSC | Yes (default) | No | No |
| PPR | Yes (new!) | No | No |
| Server Actions | Yes | Actions | No |
| Turbopack | Stable (76% faster) | Vite | Vite |
| Static export | Yes | Limited | Yes (default) |
| Edge runtime | Yes | Yes | Yes |
| Image optimization | Built-in | Manual | Built-in |
| Deploy | Anywhere (Vercel optimized) | Anywhere | Anywhere |
Need to scrape data from any website and get it in structured JSON? Check out my web scraping tools on Apify — no coding required, results in minutes.
Have a custom data extraction project? Email me at spinov001@gmail.com — I build tailored scraping solutions for businesses.
Top comments (0)