skip to content

A Next.js team wants per-request logic that needs a Node-only SDK and a database lookup, and the logic feels like it belongs in `middleware.ts`. How would you restructure it so it can actually run?

level: seniorimportance: must knowfreq 55%

answer

  1. capability question is really a placement question
  2. split the cheap check from the data lookup
  3. the destination already runs Node
  4. runtime segment export names the tier
  5. Node middleware exists, at a cost

basics

~20 s

Split the work by runtime. Keep in middleware only what the Edge Runtime can do from the request itself, and move the Node SDK and database lookup into a route handler, Server Component or Server Action running the Node.js runtime, which is their default.

solid answer

~60 s

I would treat it as a placement problem rather than a bundling one. Middleware runs in the Edge Runtime with Web-standard APIs only, so a Node SDK and a database driver cannot live there. What can live there is whatever the request alone decides — reading a cookie, inspecting the URL, verifying a signature with Web Crypto, or calling an HTTP service with `fetch`. Everything else moves down a tier to the destination: a route handler under `app/api/**/route.ts`, a Server Component, or a Server Action, all of which run in the Node.js runtime by default and can state it with `export const runtime = 'nodejs'`. If the decision genuinely cannot be made without the database, middleware is the wrong surface — do it where the data lives. Recent Next versions do let middleware opt into the Node.js runtime with `export const config = { runtime: 'nodejs' }`, but that trades away the edge execution profile and is not supported on every host, so I would reach for it last.

code

typescript · 10 lines
typescript
// app/api/entitlements/route.ts
import { NextResponse } from 'next/server'
import { readFile } from 'node:fs/promises'

export const runtime = 'nodejs'

export async function GET() {
  const plans = JSON.parse(await readFile('./data/plans.json', 'utf8'))
  return NextResponse.json({ plans })
}

go deeper

for a junior

Know that Node-only work belongs in a route handler or Server Component rather than middleware, and that those surfaces run in the Node.js runtime by default.

for a middle

Explain the split concretely: which half of a check is request-shaped and edge-safe, which half needs data, and how the runtime segment export declares where a route handler executes.

for a senior

Show the reasoning about cost as well as capability — that middleware is a hot pre-route path, that calling your own API from it doubles work, and how you split shared library modules so the middleware bundle stays clean.

for a principal

Own the tiering rule for the codebase: what is allowed to run before routing, what must run where the data lives, and under what conditions you would accept Node middleware and its deployment constraints.

## Reframe it before you solve it The instinct is "how do I get this library into middleware". The productive question is "which tier of the app has the capabilities and the data this decision needs". Middleware is a pre-route hook running in a Web-standard sandbox; a Node SDK plus a database query is application work. Once you say that out loud the design falls out. ## Step one: separate the decision from the data Most logic that *feels* like middleware is actually two things fused together. Take "only paying customers may reach the reports section": - **Request-shaped part** — is there a session cookie, does its signature verify, does its payload carry a plan claim? All of this is Web Crypto and string work, and all of it is edge-safe. - **Data-shaped part** — is that subscription still active in the database right now, and what does the billing SDK say? This needs Node. The request-shaped half can stay in middleware. The data-shaped half moves to where the data already is. ## Step two: choose the destination tier Three Node surfaces exist in the App Router, and they run in the Node.js runtime by default: - **Route handler** (`app/api/**/route.ts`) — when you need an HTTP endpoint, including one that middleware itself can call over `fetch`. - **Server Component** — when the result feeds the page being rendered anyway. - **Server Action** — when the work is a mutation invoked from your own UI. You can state the runtime explicitly on the segment: ```ts // app/api/entitlements/route.ts export const runtime = 'nodejs' ``` That export is documentation more than configuration for Node — it is the default — but it is load-bearing when you deliberately want `'edge'` instead. ## Step three: decide how the tiers communicate Two shapes are common. Either middleware makes the cheap decision itself and lets the request through so the destination does the expensive part, or middleware calls a route handler over `fetch` and acts on the answer. The second is tempting and usually wrong: you have added a network round trip in front of every matched request, and the destination will need the same data a moment later anyway. Prefer letting the request proceed and doing the work once, in the tier that owns it. ## Step four: consider Node middleware, but consider it last Recent Next versions allow middleware itself to run in the Node.js runtime: ```ts // middleware.ts export const config = { runtime: 'nodejs' } ``` This was introduced experimentally in Next 15.2 and later stabilised. It genuinely solves the capability problem — Node built-ins and native modules become available. What it costs is the execution profile people chose middleware for: the isolate's fast startup and the option of running close to the user. It also constrains where you can deploy, since support is platform-dependent. Reach for it when the logic truly must run before routing *and* truly needs Node — otherwise the split above leaves you with a faster, more portable system. ## What good answers sound like A candidate who has shipped this talks about dependency hygiene as much as architecture: the shared `lib/session` module gets split into an edge-safe verifier and a Node-side loader, so the middleware bundle stops accidentally inheriting a database driver through three levels of imports. They also mention that middleware sits on a very hot path, so pushing work down a tier is often the right call even when the edge *could* technically do it. ## The anti-patterns Aliasing Node built-ins to browser shims to force a driver into the bundle; calling your own API from middleware on every request; duplicating the same entitlement logic in both tiers so the two drift apart. Each of these is a symptom of refusing the placement decision rather than making it.

  • Why not just have middleware call your own route handler over fetch to do the Node work?
    Because it puts a network round trip in front of every matched request, and the destination usually needs the same data moments later anyway — so you pay twice. It also couples availability: your route handler going slow now slows every page in the matcher. It is defensible only when the answer is cacheable and genuinely changes routing.
  • What do you actually give up by opting middleware into the Node.js runtime?
    The execution profile the Edge Runtime was chosen for: fast isolate startup and the option of running close to the user. You also narrow your deployment choices, since Node middleware support is platform-dependent. In exchange you get Node built-ins and native modules, which is worth it only when the logic must run before routing and cannot be moved.
  • How do you stop a shared helper from dragging Node dependencies back into the middleware bundle over time?
    Split it by capability rather than by feature: an edge-safe module that only parses and verifies the request, and a Node module that loads data. Keep the edge module free of imports that reach a driver, and let a lint rule or dependency check enforce the boundary — otherwise the first well-meaning refactor reunites them.

saying these in an interview costs you the question

  • Tries to alias or polyfill a database driver into the middleware bundle
  • Has middleware fetch its own API on every request as a default
  • Duplicates the same logic in middleware and the route until they drift
  • Assumes Node middleware is free and works on every host
  • Says the work must be in middleware because it is per-request

context