Luca Mele
Luca Mele

Architecture

Kill the Java BFF: Why Small Teams Should Go Fullstack with Next.js

Kill the Java BFF: Why Small Teams Should Go Fullstack with Next.js
Back to all posts
·11 min read
Listen to the AI-generated podcast
All podcasts →

Here's a setup I've seen at multiple companies: a small Scrum team — maybe 6 to 8 people — maintaining a React frontend and a Java Spring Boot "Backend for Frontend" (BFF). The BFF doesn't own business logic. It fetches data from microservices, reshapes it, and serves it as JSON to the React app. It's a proxy with types.

This architecture made sense when it was introduced. The backend team wrote Java, the frontend team wrote JavaScript, and the BFF was where they met. But in 2026, with TypeScript mature, Next.js battle-tested, and Node.js outperforming the JVM for I/O workloads, it's worth asking: should we still maintain two ecosystems for what is essentially one product?

I'm going to make the case for consolidating to Next.js — but I'm also going to be honest about where Java wins. This isn't a hot take. It's an architecture decision that should be made with numbers, not feelings.

The BFF Pattern: What It Is and Why It Exists

The Backend for Frontend pattern, coined by Sam Newman, exists for a good reason: different clients (web, mobile, TV) need different data shapes from the same backend services. Rather than forcing the frontend to orchestrate multiple API calls and transform data, the BFF does it server-side.

In practice, most BFFs I've seen in small teams do three things: aggregate multiple service calls into one response, transform data into the shape the UI needs, and handle authentication/session management. That's it. No complex business logic. No heavy computation. Just I/O orchestration.

The question isn't whether you need a BFF layer — you probably do. The question is whether that layer needs to be a separate Java application maintained by separate people with a separate build pipeline.

The Same Endpoint, Two Worlds

Let's look at the same BFF endpoint in both stacks. Here's the Java version — clean, well-structured Spring Boot code:

// Java Spring Boot BFF — ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {

    @GetMapping("/{id}")
    public ProductResponse getProduct(@PathVariable Long id) {
        Product product = productService.findById(id);
        return new ProductResponse(
            product.getId(),
            product.getName(),
            product.getPrice().toPlainString(),
            product.isAvailable()
        );
    }
}

// ProductResponse.java — a separate DTO class
public record ProductResponse(
    Long id,
    String name,
    String price,
    boolean available
) {}

And here's the Next.js equivalent:

// Next.js — same logic, same language as the frontend
// app/api/products/[id]/route.ts
import { getProduct } from '@/lib/products';
import type { Product } from '@/types/product'; // shared with UI

export async function GET(
  _req: Request,
  { params }: { params: { id: string } }
) {
  const product: Product = await getProduct(params.id);
  return Response.json(product);
}

// The Product type is the same one the React component uses.
// No code generation. No schema drift. No mapping layer.

The code is similar in length. The difference isn't in the endpoint — it's in everything around it. The Java version requires a separate project, separate dependencies (Spring Boot, Jackson, Gradle/Maven), separate CI/CD, separate Docker image, separate deployment. The Next.js version is a file in the same project your React components live in.

Argument 1: Schema Sharing Eliminates an Entire Class of Bugs

This is the strongest argument for unification, and it's not close. When your BFF is Java and your frontend is TypeScript, you have two type systems that need to stay in sync. The typical solution is OpenAPI/Swagger code generation: the Java side generates a spec, the frontend generates TypeScript types from it.

This works — until it doesn't. The spec gets out of date. Someone changes the Java DTO but forgets to regenerate. The CI step that generates types runs after the PR is merged. A field is renamed in Java but the old name lingers in the generated types until the next full rebuild. I've seen production bugs from every single one of these scenarios.

With a unified TypeScript stack, this entire problem disappears. Not "gets better" — disappears. You define the type once, and both the API route and the React component import the same file. Rename a field, and the compiler catches every usage instantly. Share a validation function between server and client. No generation step, no sync, no drift.

// types/product.ts — ONE type, used everywhere
export interface Product {
  id: string;
  name: string;
  price: number;
  available: boolean;
  variants: Variant[];
}

// Server: used in API route, validation, DB query typing
// Client: used in React component props, form state, tests
// No OpenAPI generation, no code-gen CI step, no version mismatch

// Shared validation — also runs on both sides
export function validateProduct(p: Partial<Product>): string[] {
  const errors: string[] = [];
  if (!p.name?.trim()) errors.push('Name is required');
  if (p.price != null && p.price < 0) errors.push('Price must be positive');
  return errors;
}

Argument 2: Team Efficiency and Bus Factor

In a traditional split, you need specialists on both sides. The Java developers can't meaningfully contribute to the React code (and usually don't want to). The frontend developers can't touch the BFF (and are often not allowed to). This creates bottlenecks: a backend dev is sick, and no one can change the API. A frontend dev is on vacation, and a critical UI fix waits.

With a unified stack, every developer can work on every layer. A "frontend" task that requires an API change? One developer, one PR, one review. No cross-team coordination, no API contract negotiation, no waiting for the other side to deploy first.

The bus factor — the number of people who can be hit by a bus before the project stalls — improves dramatically. Instead of 1-2 people who understand each side, you have 3-4 people who understand the whole thing.

And there's the headcount argument. If your BFF is a thin I/O layer (which most are), you don't need dedicated backend developers for it. Three strong TypeScript developers can replace two backend + two frontend developers, with better throughput because they're not blocked by handoffs.

Traditional BFF setup (8 people):
┌─────────────────────────────────┐
│  2 Backend Devs (Java/Spring)   │ ← Own ecosystem, own tooling
│  1 Backend Lead                 │
├─────────────────────────────────┤
│  2 Frontend Devs (React/TS)     │ ← Different ecosystem
│  1 Frontend Lead                │
├─────────────────────────────────┤
│  1 QA · 1 Scrum Master          │
└─────────────────────────────────┘
  API contract sync: OpenAPI / Swagger
  Schema drift: constant risk
  Bus factor: 1 per side

Unified fullstack setup (6 people):
┌─────────────────────────────────┐
│  3 Fullstack Devs (TS/Next.js)  │ ← One ecosystem
│  1 Tech Lead                    │
├─────────────────────────────────┤
│  1 QA · 1 Scrum Master          │
└─────────────────────────────────┘
  Schema: shared TypeScript types
  Bus factor: any dev can work on any layer
  25% less headcount, same throughput

Argument 3: One Ecosystem Instead of Two

Two tech stacks means two dependency management systems (npm + Maven/Gradle), two CI pipelines, two Docker base images, two sets of security patches to track, two linters, two formatters, two test frameworks, two debugger configurations. It's not just the runtime — it's the entire operational overhead.

One ecosystem means one package.json, one CI config, one Dockerfile, one set of dependencies to audit, one linter config, one way to write tests. New developers onboard faster because there's one thing to learn, not two.

This sounds small, but it compounds. Every CVE in a Spring Boot dependency is a ticket. Every major Java version upgrade is a project. Every Gradle plugin conflict is an afternoon. With one ecosystem, you cut this operational tax in half.

Argument 4: Cloud Performance and Cost

For I/O-bound BFF workloads — which is what most BFFs are — Node.js uses significantly less memory and starts dramatically faster than the JVM. This matters for both serverless (cold starts) and containerized deployments (density and scale-up speed).

The JVM's memory overhead comes from its runtime: class loading, JIT compilation warmup, garbage collector metadata. A Spring Boot app doing nothing uses 200-400 MB. A Node.js server doing nothing uses 30-50 MB. For a BFF that's fetching, transforming, and serving JSON, this overhead is pure waste.

Node.js's event loop is also architecturally suited to BFF workloads. A BFF spends most of its time waiting for upstream services to respond — exactly the kind of I/O-heavy, CPU-light work where Node.js's single-threaded async model outperforms thread-per-request models.

Cloud cost comparison (BFF serving JSON, ~1000 req/s):

Java Spring Boot (JVM):
  Memory:     512 MB–1 GB baseline (JVM heap)
  Cold start: 3–8 seconds
  Container:  ~350 MB image (JDK + fat JAR)
  Monthly:    ~$180–280 (2 vCPU, 1 GB RAM)

Node.js (Next.js API routes):
  Memory:     80–150 MB baseline
  Cold start: 200–800 ms
  Container:  ~80 MB image (Alpine + Node)
  Monthly:    ~$60–120 (1 vCPU, 512 MB RAM)

For I/O-bound BFF workloads (fetch → transform → respond),
Node.js uses 50–70% less memory and starts 5–10x faster.
Java wins at CPU-heavy computation, not at proxying JSON.

The Honest Counter-Arguments: Where Java Wins

If this were a simple decision, everyone would have already made it. Java has real, significant advantages that you should weigh honestly:

Type safety and correctness: Java's type system is stricter and more mature than TypeScript's. TypeScript has "any" escape hatches, structural typing that can hide mismatches, and no runtime type checking. Java's nominal types, checked exceptions, and compile-time guarantees catch entire categories of bugs that TypeScript allows. For safety-critical systems (finance, healthcare), this matters.

CPU-intensive work: If your BFF does significant computation — data aggregation, encryption, complex transformations — the JVM's JIT compiler and optimized threading model will outperform Node.js significantly. Node.js is single-threaded by design. You can work around this with worker threads, but you're fighting the architecture.

Mature ecosystem for enterprise: Spring Boot's ecosystem for security (Spring Security), observability (Micrometer/Actuator), and enterprise integration (Spring Cloud) is deep and battle-tested. The Node.js equivalents exist but are less mature and less standardized.

Talent and hiring: In many enterprises, Java developers are easier to find than strong TypeScript fullstack developers. If your company's talent pool is Java-heavy, forcing a migration creates hiring friction.

Existing investment: If you have years of well-tested, well-structured Java BFF code with comprehensive integration tests, solid monitoring, and institutional knowledge — rewriting it has a real cost and a real risk. The new version will have new bugs. "Never rewrite" is too strong, but "don't rewrite without a compelling reason" is sound engineering.

When to Migrate, When to Stay

Migrate when: Your BFF is a thin I/O layer with minimal business logic. Your team is small (under 10) and handoffs between frontend/backend are a bottleneck. You're already using TypeScript on the frontend. Schema drift causes regular bugs. You're spending significant time maintaining two CI/CD pipelines. Cloud costs are a concern and you're running I/O-bound workloads.

Stay with Java when: Your BFF has significant business logic or computation. You're in a regulated industry where Java's type safety and tooling (static analysis, formal verification) provides compliance value. Your team has deep Java expertise and limited TypeScript experience. The BFF is stable, rarely changes, and doesn't cause developer friction. You're part of a larger Java ecosystem where Spring Cloud service mesh, shared libraries, and enterprise tooling provide significant value.

The worst option is a half-migration: moving some endpoints to Next.js while keeping others in Java. This gives you three ecosystems to maintain instead of two. If you migrate, commit to it fully. If Java is the right choice, own that decision and invest in making the Java BFF excellent.

How to Migrate Incrementally

If you decide to consolidate, don't do a big bang rewrite. Use the strangler fig pattern: put Next.js in front as a reverse proxy, migrate one endpoint at a time, and shut down the Java service when the last endpoint moves over.

Start with the simplest, most-changed endpoints — the ones causing the most developer friction. Migrate the types first (they're now shared), then the routes, then the tests. Each migration is a small, reviewable PR.

Run both systems in parallel with traffic comparison (shadow mode) to catch behavioral differences. The Java BFF serves production traffic while the Next.js version processes the same requests in the background. Compare responses. When they match, switch over.

This approach lets you prove the concept with low risk, build team confidence, and stop at any point if the migration isn't delivering value. Architecture decisions should be reversible when possible — this one is.

The Bottom Line

For a small Scrum team maintaining a thin Java BFF alongside a React frontend, the unified Next.js approach offers: 25% less headcount for the same throughput, zero schema drift between API and UI, higher bus factor (any dev can work on any layer), 50-70% lower cloud costs for I/O workloads, and half the operational overhead.

But it's not a universal answer. Java's strengths in type safety, CPU performance, and enterprise tooling are real. The decision depends on your team, your workload, and your constraints.

What I'd push back on is the default — the assumption that "backend = Java, frontend = React" is the only way to structure a team. For many small teams, it's not the optimal way. The best architecture is the one that lets your team ship with the least friction. Sometimes that's two ecosystems with clear boundaries. Increasingly, for BFF workloads, it's one.