Enforcing Architecture Boundaries in a Next.js App With One Test File
A Vitest file that reads the source tree and fails when a client component imports server code, an admin route skips its auth guard, or a minifier hazard creeps back in.
Rules that are invisible when broken
This is for anyone with architectural rules that live only in a README or in reviewers' heads. Some rules fail loudly when broken. A type error stops the build. Others fail silently. A client component that imports a database module may still build and work while shipping code it should not. An admin route that forgets its auth check works perfectly for an admin, and for everyone else.
When I first added this test to RPOMS AI, the commit message said it directly: a convention nobody checks is violated within a month. The file is src/lib/boundaries.test.ts. It uses no ESLint plugin and no dependency-graph tool. It walks src, reads each .ts and .tsx file as text, extracts import specifiers with a regex, and asserts.
const imports = (s: string) =>
[...s.matchAll(/(?:from\s+|import\s*\(\s*)["']([^"']+)["']/g)].map((m) => m[1]);The public page stays separate
The public checker is three files: the page, the checker component and the root layout. The test finds exactly those three, failing if the count changes, so a rename cannot quietly skip the check. It then asserts that none of them imports anything under @/lib/ or any admin path. The public page cannot reach the database, auth or admin system even by accident, and it stays small.
Client components never touch server code or secrets
Client components are found by their first line, the "use client" directive. There must be at least six, which is a guard against the detection silently matching nothing. Two rules apply to them. None may import from a list of server-only modules: the database layer, environment access, the API helpers, audit logging, current-user resolution, admin bootstrap, the active-model resolver and the inference pipeline. And none may contain process.env at all.
The second rule is stricter than it needs to be on purpose. Next.js only inlines NEXT_PUBLIC_ variables into client bundles, so other reads would just be undefined. But a client component reading environment variables is a sign someone is about to move a secret to the wrong side. Failing early is cheaper than reviewing it later.
Every admin handler starts with the guard
The admin API lives under src/app/api/admin/. For every route.ts there, the test finds each exported GET, POST, PUT, PATCH or DELETE handler and checks that the first 400 characters of its body contain the exact guard line:
const g = await guard(request, "admin.access");
if (isGuarded(g)) return g.response;It is a textual check, so it is strict about form. That is a feature: one way of writing the guard means one thing to look for in review. guard() also refuses cross-origin writes, and it answers 404 rather than 401 or 403 so the admin API does not advertise itself. The test also requires at least twelve admin routes to be found, again so a moved folder cannot turn it into a test of nothing. A related article shows the runtime version of the same idea: calling every route without a session.
Admin pages get the same treatment. Every page under src/app/admin/, except the login page, must sit inside the (panel) route group, and that group's layout must call await requireAdmin(). A new admin page created one folder too high fails the suite.
The public response never leaks internals
The check endpoint builds its response in one object literal. The test slices the route source from that literal onward and asserts it does not mention trace, modelId, photoId, evidence or clientHash. It is crude. But it means adding the internal reasoning trace or the model's identity to the public response has to be a deliberate act that also edits a test. That fits the rest of the upload hardening.
A compiler hazard pinned as a test
The last rule is the most specific, and it came from a production failure. With postgres.js you run queries as tagged templates on a client. Written as a tagged template on a parenthesised await, the code passed every test, which run through esbuild. The Next.js production minifier rewrote it as await await db() followed by the template, which tagged the Promise instead of the client, and production crashed.
The fix was to bind the client to a variable first. The test makes sure the pattern never comes back: it fails on any parenthesised await call immediately followed by a template literal, anywhere in src. When a bug is a shape of code and not a single line, a source-scanning test is the cheapest permanent fix.
What this approach does not do
Text and regex checks can be fooled by unusual formatting, and they do not understand re-exports: a client component importing a harmless-looking module that itself imports the database would pass. They also only check what someone thought to write down. The original version of the file had a few more rules, including one against referencing the separate RPOMS system and one against ML libraries in the dependency tree. The current file focuses on the boundaries listed here. Within those limits it is fast, needs no tooling, and has run on every test pass. RPOMS AI is deployed, but no model is active in production and it has not been used on real line photos.
The work behind this
Related
Keep reading
The Test That Calls Every Next.js API Route With No Session
Five API reads in RPOMS typechecked and linted cleanly and were open to anyone. They were found by probing the built app, and now one test globs every route.ts and expects a refusal.
Hardening a Public Photo Upload: Magic Bytes, Header Dimensions and Stripping EXIF Twice
An anonymous endpoint that decodes images is easy to abuse. How RPOMS AI re-encodes photos in the browser, trusts none of it on the server, and never stores the uploaded bytes.
Storing Uploaded Photos in Postgres bytea, Served Only to Admins
Why RPOMS AI keeps inspection photos in a PostgreSQL bytea column instead of a bucket, how they are served only through an admin-guarded route, and when this stops being a good idea.