portfolio Anshul Bisen
ask my work

Bootstrapping a Next.js monolith when everyone says microservices

I shipped our entire fintech platform as a single Next.js application. Here is why a monolith was the right call for a team of three.

The first architecture diagram I drew for FinanceOps had six boxes connected by arrows. An API gateway, an auth service, a payment processing service, a notification service, a reporting service, and a frontend. It looked impressive on a whiteboard. Then I counted the people who would build and operate all of it: me. Just me, for at least the first three months.

I threw away that diagram and drew a single box labeled “Next.js.” API routes, server components, background jobs via cron endpoints, and the React frontend. One deployable unit. One repository. One set of environment variables. One thing to monitor at 3am when something breaks.

Where the data model or query started fighting back.

What felt like a technical post at the time was usually me learning management in disguise. I was figuring out, in public, that your first engineering culture is just the pile of defaults you create when nobody else is in the room yet.

The meeting-room version of the technical scar.

Why Microservices Are a Trap at Pre-Seed

Microservices solve organizational problems, not technical problems. They let teams deploy independently, own their domain boundaries, and scale services with different resource profiles. When your team is three people and you are all working on everything, those benefits are meaningless. What you get instead is the operational overhead without the organizational payoff.

  • Service discovery and networking between six containers adds latency and debugging complexity.
  • Distributed transactions across a payment service and a ledger service require saga patterns or two-phase commits. We had neither the time nor the need.
  • Six separate CI/CD pipelines, six sets of health checks, six log streams to correlate when debugging a single user request.
  • Schema changes that span services require coordinated deployments. With a monolith you just deploy once.
  • Local development with docker-compose managing six services is painfully slow on a MacBook Air.

The microservices tax at our scale was roughly 40% of total engineering effort going to infrastructure instead of product. I know because I tracked it during the first two weeks of attempting the distributed approach before abandoning it.

The Monolith Boundaries That Keep It Clean

A monolith does not have to be a mess. The key is drawing module boundaries inside the application that look like the service boundaries you would draw if you were doing microservices. Same domain thinking, same separation of concerns, just without the network calls.

src/
modules/
payments/
payments.service.ts
payments.routes.ts
payments.types.ts
payments.test.ts
reconciliation/
reconciliation.service.ts
reconciliation.routes.ts
reconciliation.types.ts
notifications/
notifications.service.ts
notifications.queue.ts
auth/
auth.service.ts
auth.middleware.ts

Each module owns its types, routes, services, and tests. Cross-module communication happens through imported function calls, not HTTP requests. The function signatures serve as the API contract. TypeScript enforces the contract at compile time instead of at runtime through API schemas.

When a module gets big enough or busy enough to justify extraction, the boundary is already clean. I can pull payments into its own service by replacing the function imports with HTTP calls and deploying separately. The refactoring path is obvious because the interface already exists.

Next.js 14.2 Made This Actually Work

The specific version matters. Next.js 14.2 with the App Router gave us three capabilities that made the monolith viable for a fintech product. Server Components eliminated the need for a separate BFF layer. Route Handlers gave us API endpoints that live alongside the pages they serve. And Server Actions let us handle form submissions without manual API wiring.

Our dashboard pages fetch data directly from PostgreSQL inside Server Components. No API endpoint, no serialization, no client-side data fetching library. The component is the API. For mutations we use Server Actions with Zod validation. For webhooks from payment processors we use Route Handlers with signature verification middleware.

// Server Component — no API needed
async function RecentPayments() {
const payments = await db.query.payments.findMany({
where: eq(payments.tenantId, getCurrentTenant()),
orderBy: desc(payments.createdAt),
limit: 20,
})
return <PaymentTable data={payments} />
}

This pattern handles 90% of our read paths. The remaining 10% are webhook handlers and third-party integrations that genuinely need API endpoints. Those are Route Handlers in the same codebase.

When We Will Break It Apart

I am not ideologically opposed to microservices. I am opposed to premature architecture. We have a clear signal for when extraction makes sense: when a module needs to scale independently because its resource profile diverges from the rest of the application. Payment webhook processing is the most likely first extraction because it is CPU-bound during reconciliation while the rest of the application is I/O-bound.

The system after the boring-but-correct fix.

The builder phase was less glamorous than people imagine. It was mostly a series of stubborn, unfashionable choices that kept future-me out of 2 a.m. incident calls. I still make the same kind of choices inside portfolio, pipeline-sdk, and dotfiles.

A monolith is not the final architecture. It is the starting architecture that lets you learn your domain before committing to boundaries you cannot easily undo.

We are six months in and the monolith is handling thousands of transactions daily. Deploy time is under two minutes. Local dev starts in four seconds with Turbopack. I have zero regrets. When the time comes to split, the module boundaries are ready.

The monolith decision aged well because it optimized for the constraint that actually mattered: developer throughput with a small team. Every feature lived in one repository, one deployment pipeline, one debugging session. When the team grew to five engineers, nobody asked to break it apart. They asked for better testing and clearer module boundaries within the monolith. That is the real sign of a good architecture decision — when growth demands refinement, not replacement.