Next.js: When React Needs a Framework
React is a library, not a framework. I kept hearing that, but I didn't understand what it meant until my create-react-app project needed routing, SSR, API endpoints, and image optimization. Configuring everything myself was exhausting. Then Next.js entered the chat.
The Create-React-App Breaking Point
After adopting React Query, my data layer was solid. But everything else was held together with duct tape.
My project had:
- React Router v6 (third migration, by the way)
- A custom webpack config I was afraid to touch
- An Express server I wrote for API routes
- A hacky SSR setup that broke every other week
- Manual code splitting with
React.lazy - An image optimization pipeline involving three npm packages
My package.json had 47 dependencies. I could name maybe 30 of them.
One day, create-react-app printed a deprecation warning. The project was being sunset. I panicked. Then I realized: this was the push I needed.
”React Is a Library, Not a Framework”
I kept hearing this, but I didn’t truly get it until I listed everything React doesn’t give you:
- Routing → React Router / TanStack Router
- Server-side rendering → DIY with Express / custom setup
- Data fetching → React Query / SWR / custom hooks
- API routes → Express / Fastify / separate backend
- Code splitting → Manual React.lazy + Suspense
- Image optimization → sharp / custom pipeline
- File-based routing → Nope
- Metadata / SEO → react-helmet / custom
- Build tooling → webpack / Vite / custom config
- Deployment → Figure it out yourself
React gives you components and a rendering engine. Everything else is your problem.
A framework makes those decisions for you.
Enter: Next.js
Next.js is a React framework. It takes React and adds everything you’d eventually need anyway:
React alone: React + Next.js:
├── You build routing ├── File-based routing ✓
├── You build SSR ├── SSR / SSG / ISR ✓
├── You build API ├── API routes ✓
├── You optimize ├── Image optimization ✓
├── You configure ├── Zero-config ✓
└── You cry └── You ship
File-Based Routing: The First “Aha”
In React Router, you define routes in code:
// React Router v6
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/blog" element={<Blog />} />
<Route path="/blog/:slug" element={<BlogPost />} />
<Route path="/dashboard" element={<Dashboard />}>
<Route path="settings" element={<Settings />} />
<Route path="profile" element={<Profile />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
);
}
In Next.js, your file structure IS your routes:
app/
├── page.jsx → /
├── about/
│ └── page.jsx → /about
├── blog/
│ ├── page.jsx → /blog
│ └── [slug]/
│ └── page.jsx → /blog/:slug
├── dashboard/
│ ├── layout.jsx → Shared dashboard layout
│ ├── settings/
│ │ └── page.jsx → /dashboard/settings
│ └── profile/
│ └── page.jsx → /dashboard/profile
└── not-found.jsx → 404 page
No route configuration file. Create a file, get a route. Delete the file, route’s gone.
I migrated my 20-route app in an afternoon. With React Router, adding a new page was: create component, import it, add route, test. With Next.js: create file. Done.
Layouts: Nested and Persistent
My old app had a layout problem. Every page needed a navbar and sidebar, but I was wrapping them manually:
// Old way: every page repeats the layout
function DashboardSettings() {
return (
<DashboardLayout> {/* Re-renders on every navigation */}
<Settings />
</DashboardLayout>
);
}
function DashboardProfile() {
return (
<DashboardLayout> {/* Re-renders again */}
<Profile />
</DashboardLayout>
);
}
Next.js layouts persist between navigations:
// app/dashboard/layout.jsx
export default function DashboardLayout({ children }) {
return (
<div className="flex">
<Sidebar /> {/* Never re-mounts between dashboard pages */}
<main>{children}</main>
</div>
);
}
// app/dashboard/settings/page.jsx
export default function Settings() {
return <SettingsForm />; // Only this part changes
}
// app/dashboard/profile/page.jsx
export default function Profile() {
return <ProfileEditor />; // Sidebar stays mounted
}
Navigate between /dashboard/settings and /dashboard/profile? The sidebar stays. No re-mount. No flash. Instant transitions.
The Rendering Spectrum
This is where Next.js really clicked for me. It’s not just SSR. You choose the rendering strategy per page:
Static Site Generation (SSG)
Generated at build time. Perfect for content that doesn’t change often:
// app/blog/[slug]/page.jsx
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
return <Article post={post} />;
}
Built once. Served from CDN. Instant load. This blog post? Static.
Server-Side Rendering (SSR)
Generated on every request. For dynamic, personalized content:
// app/dashboard/page.jsx
export const dynamic = 'force-dynamic';
export default async function Dashboard() {
const user = await getCurrentUser();
const stats = await getUserStats(user.id);
return <DashboardView user={user} stats={stats} />;
}
Fresh data on every request. Personalized per user.
Incremental Static Regeneration (ISR)
The best of both worlds. Static, but revalidates:
// app/products/[id]/page.jsx
export const revalidate = 3600; // Regenerate every hour
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return <Product product={product} />;
}
First request builds the page. Serves from cache for an hour. After that, rebuilds in the background. Users always get fast loads. Data stays reasonably fresh.
API Routes: The Backend You Didn’t Know You Needed
Before Next.js, I had a separate Express server for my API:
// server.js — separate project
const express = require('express');
const app = express();
app.get('/api/users/:id', async (req, res) => {
const user = await db.users.findOne({ id: req.params.id });
res.json(user);
});
app.post('/api/posts', async (req, res) => {
const post = await db.posts.create(req.body);
res.json(post);
});
Two repos. Two deploys. Two sets of env variables. CORS configuration.
Next.js API routes live right next to your pages:
// app/api/users/[id]/route.js
export async function GET(request, { params }) {
const user = await db.users.findOne({ id: params.id });
return Response.json(user);
}
// app/api/posts/route.js
export async function POST(request) {
const body = await request.json();
const post = await db.posts.create(body);
return Response.json(post);
}
Same repo. Same deploy. No CORS. API and frontend share types, utilities, and database connections.
Server Actions: Forms Without API Routes
Next.js 13+ introduced Server Actions. You can mutate data without writing an API endpoint:
// app/posts/new/page.jsx
async function createPost(formData) {
'use server';
const title = formData.get('title');
const content = formData.get('content');
await db.posts.create({ title, content });
redirect('/posts');
}
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Write something..." required />
<button type="submit">Publish</button>
</form>
);
}
No API route. No fetch call. No loading state management. The form just… works. Even without JavaScript.
When I first saw this, I thought about my Full Circle post. This is literally how PHP forms worked.
Image Optimization For Free
My old image pipeline:
// Before: manual optimization nightmare
import sharp from 'sharp';
// + custom loader
// + responsive images logic
// + lazy loading implementation
// + blur placeholder generation
// + WebP conversion
<img src="/images/hero.jpg" /> // 2MB unoptimized image
Next.js:
import Image from 'next/image';
<Image
src="/images/hero.jpg"
width={800}
height={600}
alt="Hero image"
placeholder="blur"
/>
Automatically:
- Resizes to the right dimensions
- Converts to WebP/AVIF
- Lazy loads below the fold
- Generates blur placeholder
- Serves from CDN
One component. Zero configuration.
The Migration
Moving from create-react-app to Next.js wasn’t painless. Here’s what changed:
| Before (CRA) | After (Next.js) |
|---|---|
| React Router config | File-based routing |
| Custom SSR server | Built-in SSR |
| Express API server | API routes |
| Manual code splitting | Automatic |
| react-helmet for SEO | Built-in metadata |
| Custom image pipeline | next/image |
| webpack.config.js | next.config.js |
| 47 dependencies | 23 dependencies |
I deleted more code than I wrote. That’s usually a sign you’re doing something right.
The “Framework Lock-in” Concern
“But what about lock-in?” people asked.
Here’s my take: your React components are just React components. useQuery, useState, useEffect — those work anywhere. The Next.js-specific parts are routing and data fetching patterns.
If you ever leave Next.js, you’d need to:
- Set up routing again
- Set up SSR again
- Set up API routes again
In other words, you’d need to rebuild everything Next.js gave you for free. That’s not lock-in. That’s value.
What I Wish I’d Known Earlier
-
The App Router is the future. Pages Router still works, but new features go to App Router. Start there.
-
Not everything needs SSR. Static pages are faster and cheaper. Use
generateStaticParamsfor content-heavy pages. -
Server Components are the default. Add
'use client'only when you need interactivity. Start with the smallest client boundary. -
Middleware is powerful. Auth checks, redirects, A/B testing, geolocation — all at the edge, before your page even renders.
-
next/imageis non-negotiable. The performance difference is massive. Use it everywhere. -
Deploy to Vercel for the easiest path. But Next.js works on any Node.js host. Docker, AWS, self-hosted — all fine.
The Journey Continues
Next.js gave me the framework I didn’t know I was building myself, piece by painful piece. Routing, rendering, API routes, image optimization, SEO — all in one package.
But React itself kept evolving. New hooks. A compiler. A shift in how the community thinks about building apps.
Time to zoom out and look at where the React ecosystem landed.
P.S. — The day I deleted my webpack config, my Express server, and my custom SSR setup, I felt lighter. Like cleaning out a closet full of things you kept “just in case.” Turns out, I never needed the “just in case.” I needed a framework that handled it for me.
Saurav Sitaula
Software Architect • Nepal