next.js: an honest review

Josef Erben

Jan 22, 2025

postImage

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.

Fuzzy search on the full path in VSCode is your friend.

πŸ‘ 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.

Our working memory is limited (https://github.com/zakirullin/cognitive-load)

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.

Build, measure, learn πŸ‘

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