← All posts

Next.js in the Real World

Where the framework shines and where it fights you — an honest take on building with Next.js across multiple production projects.

I've built three production applications on Next.js over the past six months: the Macrolific marketing site, Composer Catalog, and SyncMusicTag. Each one started from roughly the same stack — Next.js, Prisma, Neon, Tailwind, deployed to Vercel — and each one taught me something different about where the framework earns its reputation and where it quietly makes your life harder.

This isn't a tutorial. It's the post I wish I'd had before I started.

Where It Actually Delivers

The App Router is the real deal. I was skeptical of the file-system routing that shipped with Next.js 13, because the Pages Router had worked fine for years and the migration path felt like churn. But after living with the App Router through several full project cycles, I don't want to go back.

Server Components changed how I think about data fetching. Instead of deciding between getServerSideProps and getStaticProps and then wiring up loading states on the client, I just fetch inside the component. The data is there when the component renders. The mental model is simpler and the resulting code is less defensive. On Composer Catalog, the catalog browse page went from a tangle of API calls and skeleton loaders to a single async component that reads from the database and renders. The page is faster and the code is cleaner.

The static/dynamic flexibility is also genuinely useful. The same codebase can produce fully static pages (the marketing content on macrolific.com), dynamically rendered pages that run server-side on every request (authenticated dashboard views), and everything in between. I don't have to pick one mode for the whole project. That flexibility pays off when a product evolves — a page that started static often needs to become dynamic later, and in Next.js that's usually a one-line change.

Vercel deployment is boring in the best way. Push to main, it builds, it deploys, it sets up preview URLs for branches. I spend almost no time thinking about deployment infrastructure for these projects. For a small studio that ships multiple products, that matters.

Route Handlers are a clean way to build internal APIs. I use them for webhook receivers, form submissions, and lightweight data endpoints. They co-locate with the pages that use them, they get the same edge runtime options, and they don't require a separate Express server.

Where It Fights You

Version upgrades are a real cost. Next.js ships breaking changes at a pace that doesn't always feel proportionate to the developer productivity gains. Upgrading from 14 to 15, and then from 15 to 16, each required dedicated debugging sessions on every project.

The 16 upgrade hit hardest. Middleware was renamed to proxy.ts, which is a straightforward rename but affects every project that uses middleware for auth. Route handler params became async, which means any handler that reads a dynamic segment like [id] now requires await params before accessing the values. Both changes are reasonable in isolation — the async params change aligns with how the rest of the framework handles async operations — but they're easy to miss in the release notes and the errors they produce aren't always obvious.

Turbopack, the new default dev bundler, introduced its own quirk: accessing the dev server from a .local domain (which I use for local SSL testing) fails silently until you add allowedDevOrigins: ["*.local"] to next.config.ts. This is documented, but only if you know to look for it. The error manifests as a blank page, not a configuration warning, which makes the root cause opaque.

The App Router's caching behavior is powerful and confusing. Next.js caches aggressively by default — fetch calls, route segments, the full page output. That's great for performance in production. In development, it means that changes sometimes don't appear until you restart the dev server or bust the cache manually. More than once I've spent time debugging a "bug" that turned out to be stale cached output from a route I'd already fixed.

The distinction between Server Components and Client Components sounds clear in documentation and gets blurry in practice. Any component that uses state, effects, or browser APIs needs the "use client" directive. That's fine. But the boundary rules — which props can cross the boundary, how context works across it, what happens when a Client Component imports a Server Component — require more careful attention than the docs imply. I've gotten it wrong in ways that produced silent failures.

What I'd Tell Myself at the Start

Use the App Router from the beginning. Don't start a new project on the Pages Router because it feels safer — the ecosystem has moved on and you'll end up migrating eventually anyway.

Pin your Next.js version in package.json with an exact version, not a caret range. Automatic minor upgrades that include breaking changes are not a good time.

Read the full migration guide for every major version bump before you start the upgrade, not after you hit the first error. The changes are usually well-documented; the problem is assuming you can skim them.

Accept that the caching model has a learning curve. Build a habit of restarting the dev server after config changes and after pulling significant changes from a branch. It saves confusion.

The Net Assessment

Next.js is the right choice for the kind of work I do — content-heavy sites that need both static and dynamic behavior, small-to-medium SaaS products, anything that lands on Vercel. The App Router is a genuine improvement over what came before. The framework moves fast, which means real productivity gains and real upgrade costs, sometimes in the same release.

I'm not switching. But I go in with clear eyes now about what the maintenance overhead actually looks like, and I plan upgrade cycles as deliberate work rather than treating them as automatic.

The framework earns its place. It just also earns its place on your backlog.