How to Enforce Module Boundaries in a Next.js Monolith
Most teams that “chose microservices” got distributed coupling instead. Here’s how to keep one Next.js deploy while making module boundaries real: public module APIs, ESLint import zones, per-module table ownership, and an anti-corruption layer for WordPress.
in Production Stacks9 min readUpdated

A four-person team runs a Next.js app, a “BFF” service, an auth service, a jobs worker and headless WordPress. Adding a paid-content gate means a coordinated release across three of those, a contract change in the fourth, and a staging environment nobody trusts. That team doesn’t have microservices; it has one domain model sprayed across several deploy units with HTTP between the layers.
The position here: one deploy unit, several modules whose boundaries are enforced by tooling, and a network boundary only where something genuinely needs to scale, fail, or ship on its own schedule. That gets you most of the design benefit of separate services and defers almost all of the operational cost. It also has real downsides, named later in the piece.
“Modular monolith” means rules, not folders
The term is an industry pattern, not a feature shipped by a framework, and no single vendor’s docs own the definition. What makes a codebase modular is narrower than a directory convention: each module declares a public surface, dependencies point one direction, and a violation fails CI rather than getting caught in review, or not.
A src/modules/ tree with unrestricted cross-imports is the same tangle with nicer paths. The interesting question is never “where does this file live” but “what is allowed to import it, and what happens when someone does it anyway”.
Microservices solve enforcement by making the wrong import physically impossible. You pay for that with partial failure, versioned contracts, distributed tracing, and deploy choreography, on every feature, forever. In a monolith, enforcement is cheap to add and cheap to remove, which is exactly why it needs to be automated. If you’re still weighing the split at an organizational level, the trade-offs are laid out in Modular Monolith vs Microservices: How to Decide in 2026.
Drawing the modules in a Next.js app
Treat the app/ tree as a delivery layer and nothing else. Pages, Route Handlers and Server Actions parse input, call a module function, and format the result. No ORM calls, no fetch to WordPress, no pricing rules in page.tsx. The moment a route file imports your database client directly, the boundary is gone and no amount of folder structure brings it back.
app/
(marketing)/blog/[slug]/page.tsx
api/webhooks/wordpress/route.ts
src/
modules/
billing/ index.ts internal/ billing.test.ts
catalog/ index.ts internal/
content/ index.ts internal/
shared/ result.ts logger.ts db/client.ts
proxy.tsOne detail worth getting right early: a module’s public functions should return plain serialisable data, not ORM entities or class instances. That way the same function serves a Server Component render and a Route Handler response without a second mapping layer, and you don’t discover at the boundary that a Decimal or a Date wrapper won’t cross into a Client Component. The related question of which components need use client at all is covered in Where ‘use client’ Belongs in the Next.js App Router.
Next.js 16 renamed middleware.ts to proxy.ts (the exported function is now proxy), specifically to make the app’s network boundary explicit; the old filename still works, but the new name is the documented convention (Next.js 16 (opens in a new tab)). Keep that file thin too. Edge-level redirects and header work belong there; authorization decisions belong in the module that owns the resource, evaluated on the server path that actually reads data.
The public surface of a module
Everything outside index.ts is private. Types that cross a boundary are defined by the module that owns the concept, not hoisted into a global types/ folder where every module ends up depending on every other module’s shape.
TypeScript helps less here than people expect. It catches shape drift once you’ve declared the boundary types, but it has no opinion about who imports what. import { priceLineItem } from '@/modules/billing/internal/pricing' type-checks perfectly. Import discipline is a lint concern.
Making the boundaries fail CI
Start with no-restricted-imports in your ESLint flat config. Ban alias-path imports into any module’s internal/ directory; inside a module, import your own internals relatively, so the rule doesn’t need per-module exceptions.
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['src/**/*.{ts,tsx}', 'app/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/modules/*/internal/*', '@/modules/*/internal/**'],
message: 'Private to the module. Import from its index.ts instead.',
},
{
group: ['@/app/*', '../../app/*'],
message: 'Modules must not depend on the delivery layer.',
},
],
},
],
},
});That covers the two violations that actually happen: reaching into someone else’s internals, and a module importing route code to reuse a helper. What it does not express is a layered dependency graph, cycles between modules, or “catalog may depend on content but not the reverse”. For that you want a tool that builds the real graph, such as eslint-plugin-boundaries or dependency-cruiser. Confirm current maintenance and flat-config compatibility before you adopt one; a dead lint plugin is worse than a no-restricted-imports block you wrote yourself.
Run it on an existing repo before you announce the pattern. The first run on a codebase that grew organically will produce a violation list, and that list is your real module map: the imports that are hard to remove tell you where your boundaries are actually drawn, as opposed to where the folder names suggest they are.
The second enforcement point costs nothing: a CODEOWNERS entry per module directory, so a change to src/modules/billing/index.ts requires the billing reviewer. This matters more now that a meaningful share of diffs are machine-generated and locally plausible; an agent will happily widen a public API to make a test pass. See How to Gate Merges When an Agent Wrote Part of the Diff for the surrounding review policy.
Database ownership is the boundary that actually matters
Lint rules protect the code graph. They do nothing about the query that joins orders to wp_posts_cache from inside the catalog module, and that join is what will block an extraction two years from now.
One database is fine. One undifferentiated schema is not. Give each module its own Postgres schema, or at minimum its own table prefix, and make it a rule that no module reads another module’s tables. Cross-module reads go through a function call on the owning module, which is slower than a join and cheaper than a rewrite. When a report genuinely needs data from three modules, build it as a read model the modules write into, rather than letting the reporting code reach across every boundary you just drew.
Foreign keys across module boundaries are a deliberate negotiation, not a default. If you’re confident these two modules will never be separate processes, keep the constraint and enjoy the integrity guarantee. If a split is plausible, drop the cross-module FK and enforce the relationship in the owning module, because a constraint is the one thing you cannot carry across a network boundary later.
Keep migrations per module too. Whether your ORM supports multiple Postgres schemas natively varies by tool and version, so check that before committing to the layout, but the goal is the same either way: an extraction should move a contiguous set of tables and a contiguous set of migration files.
Headless WordPress: the service you already split
WordPress is the process your team doesn’t own. Editors install plugins, ACF field names change, a taxonomy gets renamed, and none of that goes through your PR review. That’s a hard boundary whether anyone chose it or not, which makes it the easiest module to justify.
Everything the application knows about WordPress belongs behind src/modules/content. The module’s job is translation: map REST payloads into domain types at the edge, so a plugin change touches one mapper instead of eleven components.
import 'server-only';
import type { Article } from '../index';
type WpPost = {
slug: string;
title: { rendered: string };
content: { rendered: string };
date_gmt: string;
acf?: { reading_time?: number };
};
export async function getArticleBySlug(slug: string): Promise<Article | null> {
const res = await fetch(
`${process.env.WP_URL}/wp-json/wp/v2/posts?slug=${encodeURIComponent(slug)}`,
{ next: { tags: [`wp:post:${slug}`] }, signal: AbortSignal.timeout(3000) },
);
if (!res.ok) throw new Error(`WP responded ${res.status}`);
const [post] = (await res.json()) as WpPost[];
if (!post) return null;
return {
slug: post.slug,
title: post.title.rendered,
bodyHtml: post.content.rendered,
publishedAt: post.date_gmt,
readingMinutes: post.acf?.reading_time ?? null,
};
}No rendered anywhere outside this module. No taxonomy IDs in a component. And treat bodyHtml as untrusted markup from a system with its own user accounts: sanitize it, or render it in a way your CSP can live with, rather than assuming the editorial workflow is a security control.
On transport, the trade-off is plain enough. The WordPress REST API ships with core and is stable, at the cost of multiple round trips for nested content. WPGraphQL gives you one query for a post with its author, media and terms, at the cost of a community plugin in your critical path, which is a dependency your ops story has to account for. Either choice lives entirely inside content, which is the point of putting it there.
Caching belongs in this module as well. Tag your fetches, expose a small revalidateContent() function, and call it from a Route Handler wired to a WordPress webhook so the rest of the app never reasons about content freshness. If you’ve adopted the Cache Components model that replaced the experimental ppr flag in Next.js 16, verify the tagging and revalidation APIs you use against the current docs before wiring the webhook (Next.js 16 (opens in a new tab)).
What the single deploy actually costs
The honest cost is a shared failure domain. An unhandled rejection in a background path can take down request serving, a slow WordPress origin can occupy every connection, and a memory leak in the reporting module degrades checkout. Mitigate it deliberately: move anything long-running out of the request path into a queue, put a timeout and a circuit breaker on every outbound client (the AbortSignal.timeout above is the minimum, not the whole answer), and keep per-module error handling that degrades a section of the page rather than the response.
The patch surface cuts both ways. Next.js adopted a preannounced security release program starting in July 2026, so patch dates are scheduled and announced rather than dropping without warning (July 2026 Security Release (opens in a new tab)). Two lines are supported as of September 2026: 16.3.x as Active LTS and 15.5.x as Maintenance LTS, and the releases bundle multiple CVEs each — the August 2026 release shipped 16.3.3 and 15.5.24 and fixed two Critical-severity issues (nextjs.org/blog (opens in a new tab)). One app means one upgrade to schedule and one smoke test to run. Five services on five Next.js versions means five, and in practice it means the jobs service stays two majors behind because nobody owns it.
A few things this pattern doesn’t solve. It won’t help if two parts of the system have genuinely different scaling profiles, such as a video transcoder next to a marketing site; extract that and accept the network. It won’t fix organizational coupling if fifteen engineers all edit the same three modules. And build times grow with the repo, which is less painful now that Turbopack is the stable default bundler in Next.js 16 (Next.js 16 (opens in a new tab)) but still a real number you should watch.
Start with the lint rule, not the folder move
Pick the boundary you already know is leaking, usually the CMS one. Add no-restricted-imports for internal/ and run it across the existing tree without moving a single file. Fix the violations in small PRs, add CODEOWNERS for that directory, then repeat for the next module. Only after two or three modules hold their shape under CI should you consider whether any of them deserves its own deploy, and by then the answer will be based on how the module actually behaves rather than on an architecture diagram drawn before the code existed.



