Building Modern Web Apps with Next.js 15
Working with the App Router
The web development landscape has shifted dramatically. With Next.js 15, the App Router and React Server Components aren't just incremental improvements — they represent a fundamental rethinking of how we build web applications.
This site uses the Next.js 15 App Router. The examples below explain its rendering model; they are not a benchmark or a promise that changing routers will improve every application.
React Server Components: The Core Shift
React Server Components (RSC) allow components to render entirely on the server, never shipping their JavaScript to the client. This isn't SSR as we knew it — it's a new rendering paradigm.
// This component runs on the server. Interactive descendants may still need client JavaScript.
export async function RecentProjects() {
const projects = await db.project.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 6,
});
return (
<section>
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</section>
);
}
The key insight: data fetching lives where the data is consumed. No more prop drilling from getServerSideProps, no more global state management for server data.
Default to Server Components. Only add
"use client"when you need browser APIs, state, or event handlers. Most of your application can and should remain server-rendered.
The App Router Architecture
The App Router introduces a file-system based routing model with powerful conventions:
Layouts and Templates
Layouts persist across navigations, preserving state and avoiding unnecessary re-renders. Templates re-mount on every navigation, useful for entrance animations.
src/app/
├── layout.tsx # Root layout (shared header, footer)
├── template.tsx # Page transition wrapper
├── page.tsx # Homepage
├── blog/
│ ├── layout.tsx # Blog-specific layout (sidebar TOC)
│ ├── page.tsx # Blog index
│ └── [slug]/
│ └── page.tsx # Individual blog posts
Static Generation with Dynamic Data
generateStaticParams supplies dynamic route parameters for build-time generation; the rendering and caching choices for the route still determine its behavior. generateMetadata provides per-page metadata:
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = getPostBySlug(slug);
if (!post) return {};
return {
title: post.frontmatter.title,
description: post.frontmatter.description,
};
}
Measure the Boundary You Change
Before moving a feature between server and client, record its JavaScript payload, request sequence, rendering time, and interaction behavior. Repeat the same workflow after the change. Moving a dependency out of the client graph can reduce downloads, but slow database queries or uncached server work can still delay a page.
The Next.js 15 component documentation explains where each kind of component runs and how the initial page becomes interactive.
Streaming and Suspense
Next.js 15 leverages React Suspense for streaming HTML to the browser. Heavy components don't block the entire page:
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<MetricsSkeleton />}>
<AnalyticsMetrics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
Suspense lets ready parts of the page appear while slower sections resolve. The timing still depends on the work above each boundary, the server, and the network. Choose useful fallback content and test the loading experience with realistic delays.
Server Actions: Mutations Without API Routes
Server Actions bring form handling back to its simplest form — a function that runs on the server:
'use server';
export async function submitContactForm(formData: FormData) {
// Import the same schema used by the form; do not trust type assertions.
const { name, email, message } = contactSchema.parse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
// Apply authorization and abuse controls appropriate to the operation.
await db.contactSubmission.create({
data: { name, email, message },
});
await sendNotificationEmail({ name, email, message });
revalidatePath('/admin/submissions');
}
Forms using Server Actions from Server Components support progressive enhancement. Validate inputs and handle mutation and notification failures separately so a retry does not duplicate saved data.
Choosing an Approach
Use the rendering model to simplify a specific data flow or interaction. Keep client boundaries small, design loading and error states, and verify the result on production-like data. For new deployments, check supported releases and security updates rather than choosing a version solely from an article title.
Ready to modernize your web application? Get in touch to discuss how Next.js 15 can improve your product's performance and user experience.
