next.js: an honest review
Josef Erben
Jan 22, 2025

tl;dr
Next.js 14 is fast, but App Router caveats increase cognitive load
Server Actions are awesome, once secured
Everyone is using Next.js, this is its greatest strength
Vercel as hosting platform is a joy to use and they save us tons of time
We've been using Next.js 14 to build our app, marketing website, arewedownyet.com and a few other apps.
This is an honest review of what's been working well and what hasn't.
π middleware.ts
limitations
Next.js middleware run in an environment that's neither Node.js nor browser a browser environment. Some of Nodeβs API is supported, some isnβt.
Our logging breaks in middleware.ts
because we flush logs using a Node.js worker. Code that works in other parts of Next.js suddenly doesnβt work anymore. Itβs easy to use Node API by accident.
// middleware.tsimport { NextResponse } from "next/server"import { logger } from "@acme/logger"
export async function middleware(request: Request) { try { // β This will fail if you flush using worker threads since worker_threads is not available in edge logger.flush()
// β This will always return the same timestamp until an I/O operation console.log(Date.now())
// β
After fetch, Date.now() will advance await fetch("https://api.example.com") console.log(Date.now())
// β This will fail if it takes >25s to get a response const slowResponse = await fetch( "https://slow-api.example.com", )
// β This will fail if response is >4MB const bigResponse = await fetch( "https://big-payload-api.example.com", )
return NextResponse.next() } catch (error) { // β Stack trace not useful console.error(error) return NextResponse.error() }}
I hope weβll get a choice to run middleware.ts
in Node.js at some point. Vercel already figured out how to make Node.js functions cheap and fast.
π Server Actions security
Did you know that a file with "use server"
at the top that exports async functions creates public POST routes?
"use server"
// β This will create a public POST routeexport async function getProjects(userId: string) { return db.query.projects.findMany({ where: eq(projects.userId, userId), })}
The docs mention that you should treat Server Actions like public API endpoints and Next.js 15 makes exploitation harder. However, this is still a major footgun.
Maybe "use server"
could be "use dangerously public"
.
"use dangerously public" // <-- π€¨ "better make sure we have auth"
export async function getProjects(userId: string) { if (!isAuthenticated()) throw new Error("Unauthorized") return db.query.projects.findMany({ where: eq(projects.userId, userId), })}
π server-only
not type-safe
// db.tsimport "server-only"
export async function getProjects() { return db.query.projects.findMany()}
// components/projects.tsx"use client"
// β tsc --noEmit can't catch this importimport { getProjects } from "../db"
//...
You need a 3rd party library (npm i server-only
) to make sure that server code only runs on the server.
But using server-only
you wonβt get a tsc --noEmit
error! VSCode says everything is fine, you run npm build
and boom.
Build errors further diverge from type errors.
π Unsafe props in Server Components
I have following mental model about components: Client Components should be leaves in a tree of Server Components. Seems reasonable, but there is caveat.
Did you know that promises can be passed down from Server Components to Client Components but zod schemas can't? Intuitively, a promise shouldnβt be βmoreβ serializable than a pervasive z.ZodObject
.
// server-component.tsxasync function ServerComponent() { // β
Works: primitives, arrays, plain objects const projects = [{ id: "1", name: "Project 1" }]
// β
Works: promises const promise = Promise.resolve(projects)
// β
Works: Date objects const date = new Date()
// β Won't work: class instances const set = new Set(["1", "2"])
// β Won't work: functions that aren't Server Actions const formatter = (date: Date) => date.toISOString()
// β Won't work: zod schema cannot be passed down const projectSchema = z.object({ id: z.string(), name: z.string(), })
return ( <ClientComponent projects={projects} // β
promise={promise} // β
date={date} // β
set={set} // β not serializable format={formatter} // β not serializable schema={projectSchema} // β not serializable /> )}
This is a React quirk and serializability is defined over there in React, not in Next.js. However, as a high level full-stack framework, Next.js could expose a type-safe API for server/client composition or at least offer a blessed/documented RSCSerializable
type.
π layout.tsx
caveats
Layout components can't access searchParams
or pathname
. We added x-search-params and x-pathname headers to incoming requests in our middleware.ts
to work around this limitation.
π Client Component naming
Despite the name, Client Components pre-render on the server. This is confusing.
Here some naming suggestions:
Server Component
=>Server-Only Component
Client Component
=>Sometimes-Client Component
Not much better I guess, naming is hard. π₯²
π Accidental server code bundling
Letβs say we have a file encryption.ts
that should only ever run on the server. It contains the encryption algorithm so we donβt want to serve this file by accident to the browser.
// utils/encryption.ts
import { createCipheriv, createDecipheriv, randomBytes,} from "node:crypto"
export function encrypt(key: string, text: string) { const iv = randomBytes(16) const cipher = createCipheriv("aes-192-cbc'", key, iv) const encrypted = cipher.update(text, "utf8", "base64") return encrypted + cipher.final("base64")}
// components/form.tsx
"use client"import { encrypt } from "../utils/encryption"
export function Form() { return ( <form onSubmit={(e) => { e.preventDefault() const text = e.currentTarget.text.value // β Next.js silently polyfills node:crypto const encrypted = encrypt("secret", text) }} > <input name="text" /> <button type="submit">Encrypt</button> </form> )}
It's easy to accidentally bundle and ship server-only code to the client. Next.js silently polyfills node:crypto
instead of failing the build. This unnecessarily bloats the client bundle with code that should only run on the server. Apart from bloat, this could lead to security issues in some cases.
The trade-off is that things "just work", but at the cost of potentially shipping server code to the client.
This issue is not specific to Next.js, itβs common among full-stack frameworks. Remix and SvelteKit deal with it by looking at the filename to decide whether itβs server only or not.
There is server-only
, hopefully it's going to be part of Next.js at some point.
π View Transitions
Animating between routes in Next.js App Router is tricky.
It looks like the View Transitions API is coming to React and will likely be the best solution in the future.
π Data fetching in Server Components
// app/projects/page.tsx
// The page shows a loading state while ProjectList streams inexport default function ProjectsPage() { const projects = await db.query.projects.findMany() return (<div>{projects.map((p) => p.name)}</div>)}
Itβs nice to fetch data where itβs being used. Things that belong together are together (Locality of Behavior).
π File-based routing
app/βββ page.tsx // β /βββ about/β βββ page.tsx // β /aboutβββ projects/ βββ page.tsx // β /projects βββ [slug]/ βββ page.tsx // β /projects/123
The URL structure matches the folder structure. This goes well with a βuse-firstβ dev flow, where you work your way from the user interactions to the backend and database.
The App Router's file conventions (layout.tsx
, error.tsx
, loading.tsx
, not-found.tsx
) give your project more structure. You know exactly where to put error boundaries, loading states, and shared layouts. This helps keep the codebase consistent as it grows.
There is a downside: searching for files becomes harder because you end up with a lot of page.tsx
, layout.tsx
, loading.tsx
, not-found.tsx
etc.

π Built-in components
import { Image } from "next/image";
<Image src="/hero.png" alt="Hero" width={1200} height={600} // Automatically: // - Serves .webp if supported // - Lazy loads below the fold // - Resizes for the viewport // - Prevents layout shift/>
import { Link } from "next/link";
<Link href="/about" // Automatically: // - Prefetches the route // - Handles client-side navigation // - Preserves scroll position> About</Link>
Built-in components like Image
, Link
, Script
and Font
handle web performance best practices out of the box. No need to manually optimize images, handle prefetching, or manage font loading.
π Server Actions
Despite the security concerns mentioned, it's great to have a type-safe way to call server code from the client. This makes Next.js a decent choice for building dynamic apps. No need for tRPC or, shudder, GraphQL.
π Fast by default
The upside to all the complexity introduced by the App Router: Next.js apps are just super fast by default. You have to work against the framework to make things slow.
Pages get rendered statically unless you access the request or opt-out explicitly.
π Ecosystem
Frontend development has been converging on React and Next.js for a while. When a lot of people do things the same way, good things follow:
Clear consensus on patterns
React component is the new API
First-class Next.js SDKs for everything
More shareable code (lshadcn/ui)
Well-defined, blessed paths
BUT: Next.js doesnβt use Vite. There is this awkward situation where everyone else is using Vite and the largest framework insists on Webpack and is doubling down on Turbopack. Thatβs a small π from me.
π Vercel: Frontend Cloud
Vercel as Frontend Cloud is a joy to use. Custom subdomains just work. Rollbacks are reliable. Automatic PR review environments mean we don't need a staging environment. Analytics and Core Web Vitals are easy to set up. Most of the things worked out of the box for us.
There were some hiccups with logs not being shown in the log dashboard and the CLI. Most of our services are logging to Axiom so it's not a big deal for us.
π Turborepo
Turborepo is a build tool for monorepos and not tied to Next.js. Ever since Vercel acquired Turborepo, it's often used together. Itβs rock solid when configured right, and remote caching on Vercel saves hours of compute in our growing monorepo.
π₯Ί watchPackages
This is one for the wishlist.
// next.config.mjs/** @type {import('next').NextConfig} */const nextConfig = { // ... watchPackages: ["@beepshq/some-local-package"], // ...}
It would be nice to have transpilePackages with just the watcher and no transpilation.
Summary
Next.js is a huge open-source project with over 128k stars on GitHub! There are many different customers with different needs. It's super hard to make everyone happy, if itβs possible at all.
Next.js is fast out of the box and its DX is great for static sites. However, when working on dynamic components, the complexity introduced by the App Router is showing.
The caveats around use server
, use client
, server/client composition and layout.tsx
increase cognitive load. Every exception and caveat occupies space in our brain that could be used for solving essential problems.

Vercel is a joy to use and the ecosystem is great. The markup compared to something like AWS is well worth the engineering hours saved. Don't be fooled by the "it's just an AWS wrapper" haters.
I hope that Vercel has a strategy for making smaller startups with dynamic apps great, not just huge e-commerce and marketing sites. Server Actions are a sign that they do.
Next.js, shadcn/ui and v0 are a hug step towards making everyone a web developer. This is great, because we get to spend more time on other things.
While Next.js is fast by default, Vercel has the responsibility to make it secure by default too. Especially considering how easy it is to deploy full-blown apps using v0 without a single glance at the code.
So far, Vercel does a good job listening to the community. For instance, they admitted when they were wrong about rendering on the edge.

I'm looking forward to see what's next!