Skip to main content

monolith-vs-split

Monolithic Next.js vs Split Frontend + Backend

This page compares monolithic Next.js architecture (like Bike4Mind today) with a split architecture (separate frontend + backend services), analyzing the trade-offs and performance implications of each approach.

Context: TwinSpires' existing architecture

TwinSpires already uses a split architecture with Spring Boot (Java) + Angular. This document explores how maintaining that split model while modernizing the tech stack compares to adopting a monolithic Next.js approach.


1. High-level architecture

Monolithic Next.js

Characteristics

  • One codebase, one deployment artifact.
  • Next.js handles both UI rendering and API endpoints (via /api/* routes).
  • Tight coupling between frontend concerns and backend logic.
  • Database access happens directly from API routes.
  • Performance concern: Frontend must wait for server processing before rendering the first view.

Split architecture (proposed alternative)

Characteristics

  • Separate frontend app and backend API (typically separate repos, e.g., /frontend and /backend under one GitHub group).
  • Frontend only knows about HTTP APIs.
  • Backend owns business rules, validation, and persistence.
  • Clear contract between frontend and backend via REST/JSON.
  • Performance benefit: Frontend is pre-built as static files, eliminating server-side rendering delays.

Comparison table

AspectMonolithic Next.jsSplit frontend + backend
UI renderingNext.js pages in same app as APIStandalone Angular/React app
API layerNext.js /api/* routes in same projectSeparate Express/FastAPI service
DB accessCalled directly from Next.js API routesOwned by backend service only
Change impactUI + API + DB change togetherUI and backend can change and deploy independently
Team alignmentSingle team owns everythingFrontend and backend teams can work in parallel
Runtime flexibilityLocked to Node.jsCan mix Node/TS and Python backends
Initial loadServer must process before renderingStatic files load instantly from CDN

2. Performance: The critical difference

Monolith: Server-side rendering bottleneck

In a monolithic Next.js app, the main performance issue is tight coupling between frontend and backend:

  • When a user requests a page, the server must:

    1. Process the request
    2. Execute server-side logic (database queries, API calls)
    3. Render the page on the server
    4. Send the complete HTML to the browser
  • The frontend must wait for server processing before rendering the first view, causing:

    • Higher Time to First Byte (TTFB): The browser waits for the server to generate the response.
    • Slower First Contentful Paint (FCP): Users see a blank screen until server processing completes.
    • Performance bottlenecks, especially under load or with complex server-side logic.

Example flow in monolith:

// pages/products.tsx
export async function getServerSideProps() {
// Browser waits here while server processes...
const db = await getDb();
const products = await db.product.findMany(); // Database query
const analytics = await fetchAnalytics(); // External API call
// ... more server processing
return { props: { products, analytics } }; // Finally, HTML is sent
}

The user's browser is idle during all this server processing.

Split: Static build + CDN approach

A split architecture enables a modern decoupled approach with significant performance benefits:

How it works:

  1. Build time: The frontend is pre-built into static files (.html, .js, .css) during the build process.
  2. Deployment: These static assets are distributed to CDN edge servers (e.g., CloudFront, S3).
  3. User request: When a user opens the website:
    • The browser instantly fetches pre-built files from the nearest CDN node.
    • No server processing required for initial page load.
    • The React/Angular app takes over on the client side, rendering the UI immediately.
  4. Data loading: The client-side app makes asynchronous API requests to the backend as needed (for data, dynamic content, etc.).

Performance benefits:

  • Drastically reduced TTFB: Static files are served from CDN edge locations, not from a server that needs to process requests.
  • Faster First Contentful Paint: The UI renders immediately from pre-built static files.
  • Better perceived performance: Users see content instantly, while data loads in the background.
  • Scalability: CDN handles static file delivery at global scale without backend server load.

Example flow in split architecture:

// frontend: Pre-built static files load instantly
// No server processing needed for initial render

// Once loaded, client-side app makes async API calls
const products = await fetch("/api/products").then((r) => r.json());
// Backend processes this independently, doesn't block UI

The user's browser gets immediate visual feedback while data loads asynchronously.


3. Responsibilities & data flow

Monolith: blurred boundaries

In a monolithic Next.js app, a single page component might:

  • Render UI.
  • Call getServerSideProps which directly hits the database.
  • Share types and models directly with backend code.
  • Import helpers that also mutate server state.

Over time, this leads to:

  • UI and domain logic mixed together.
  • Harder to test backend logic in isolation.
  • Harder to move parts of the system out into separate services.
  • Difficult to introduce different runtimes (e.g., Python for ML/AI features).
  • Performance coupling: UI rendering blocked by backend processing.

Split: clear contracts

Frontend responsibilities:

  • Knows only about GET /api/products, POST /api/orders, etc.
  • Treats backend as a black box.
  • Focuses on UX, routing, and presentation.
  • Renders from pre-built static files for instant initial load.

Backend responsibilities:

  • Owns validation, business rules, and persistence.
  • Can be tested and evolved independently.
  • Can be written in different languages/runtimes per use case.
  • Processes API requests asynchronously, not blocking UI rendering.

Example flow in split architecture:

// frontend: Just fetches and renders
// Static files load instantly, then async API calls
const products = await fetch("/api/products").then((r) => r.json());

// backend: Owns all business logic
// Can be Node/TS, Python, or any runtime
// Processes independently, doesn't block UI

This makes it much easier to reason about:

  • What is UI behavior vs. what is domain logic.
  • How data flows through the system.
  • Who owns what part of the system.
  • Why performance is better (static files + async API calls).

4. Code samples (monolith vs split)

Monolithic Next.js API route

pages/api/products.ts (monolith)
// UI pages and API routes live in the same repo
import type { NextApiRequest, NextApiResponse } from "next";
import { getDb } from "../lib/db";

export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const db = await getDb();

if (req.method === "POST") {
const { name, price } = req.body;
// Business logic, validation, and DB access all in one place
const product = await db.product.create({
data: { name, price, createdAt: new Date() },
});
return res.status(201).json(product);
}

if (req.method === "GET") {
const products = await db.product.findMany();
return res.status(200).json(products);
}

res.status(405).end();
}

Issues:

  • Lives in the same repo as all pages and components.
  • Business logic, DB access, and transport logic are all in one function.
  • Hard to extract for reuse or testing.
  • Can't easily swap out the runtime (e.g., move to Python for ML features).
  • Server-side rendering blocks initial page load.

Split backend (Express API)

backend/src/routes/products.ts (split API)
import { Router } from "express";
import { ProductService } from "../services/productService";
import { validateProduct } from "../validators/productValidator";

const router = Router();
const productService = new ProductService();

router.get("/", async (_req, res) => {
const products = await productService.getAllProducts();
res.json(products);
});

router.post("/", async (req, res) => {
const validation = validateProduct(req.body);
if (!validation.valid) {
return res.status(400).json({ errors: validation.errors });
}

const product = await productService.createProduct(req.body);
res.status(201).json(product);
});

export default router;
frontend/src/services/products.ts (split frontend)
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000";

export async function getProducts() {
const res = await fetch(`${API_URL}/api/products`);
if (!res.ok) throw new Error("Failed to fetch products");
return res.json();
}

export async function createProduct(product: { name: string; price: number }) {
const res = await fetch(`${API_URL}/api/products`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(product),
});

if (!res.ok) throw new Error("Failed to create product");
return res.json();
}

Benefits:

  • Clear separation: Frontend = pre-built static files + async fetch + render, Backend = validate + persist + return JSON.
  • Backend can be tested independently.
  • Backend can be swapped to Python/FastAPI for ML features without touching frontend.
  • Frontend can be swapped to Angular without touching backend.
  • No server-side rendering delay: Static files load instantly, API calls happen asynchronously.

5. Deployment & scalability

Monolith

  • One deployment unit (e.g., one Next.js app on Vercel or a single container).
  • Scaling UI also scales API whether you need it or not.
  • Harder to:
    • Put API behind a different security perimeter.
    • Move specific services to different runtimes (e.g., Python for ML/AI).
    • Scale API and UI independently.
    • Deploy frontend and backend changes separately.
  • Performance: Every request requires server processing, even for static content.

Example: If you need to update an API route (e.g., change the logic for fetching products), you must redeploy the entire Next.js app, which also redeploys the frontend even if no frontend code changed. Conversely, a simple CSS change requires redeploying all API routes as well.

Split

Benefits:

  • Separate deployment pipelines:
    • Frontend → pre-built static assets on S3 + CloudFront (fast, cheap, global CDN).
    • Backend → Lambda (Node/TS or Python), ECS, or another target.
  • Independent scaling:
    • Scale API capacity independently of UI traffic.
    • Frontend is static (no server costs for most traffic, served from CDN).
  • Performance:
    • Static files served from CDN edge locations (low latency globally).
    • No server processing for initial page load.
    • API requests processed asynchronously, not blocking UI.
  • Flexibility:
    • Easier to introduce additional backend services (e.g., Python FastAPI for ML/AI).
    • Apply stricter security and observability on APIs.
    • Deploy frontend updates without touching backend (and vice versa).

6. Developer workflow

Monolith

Pros:

  • Single repo, simple mental model at the very beginning.
  • Fast for small prototypes and MVPs.
  • No API contract to maintain between frontend and backend.

Cons as the system grows:

  • Harder to parallelize frontend and backend work (both touch same codebase).
  • Any change touches the same codebase / CI pipeline.
  • Testing and local dev require running the whole stack at once.
  • Can't easily experiment with different backend runtimes.
  • Performance debugging: Hard to isolate whether slowness is from UI rendering or backend processing.

Split

Frontend devs can:

  • Focus on UX, routing, components, without worrying about DB migrations.
  • Work independently of backend changes (as long as API contract is stable).
  • Use mock APIs or API design tools (OpenAPI/Swagger) to design UI before backend is ready.
  • Test static build performance independently (CDN delivery, bundle size, etc.).

Backend devs can:

  • Run API + DB + tests in isolation.
  • Evolve schemas, performance, and integrations independently.
  • Test API endpoints without spinning up the frontend.
  • Choose the best runtime for the job (Node/TS for web APIs, Python for ML).
  • Optimize API performance without affecting frontend build process.

CI/CD:

  • Separate pipelines for frontend and backend.
  • Faster, more targeted feedback.
  • Frontend builds can be cached and optimized independently.

Repository structure:

  • Separate repos (e.g., /frontend and /backend under one GitHub group) provide clear boundaries.
  • Each repo can have its own CI/CD, dependencies, and deployment process.
  • Easier to manage permissions and access control per team.

7. Alignment with TwinSpires' existing architecture

TwinSpires already uses a split architecture with Spring Boot (Java) + Angular. Moving to a monolithic Next.js would be a step backward from this proven model.

Current TwinSpires model:

  • Frontend: Angular (separate deployment)
  • Backend: Spring Boot/Java (separate service)
  • Clear boundaries and team ownership

Proposed split architecture maintains this pattern:

  • Frontend: Angular or React/Next.js (separate deployment, static build + CDN)
  • Backend: Express/Node/TS or FastAPI/Python (separate service)
  • Same clear boundaries, modernized tooling
  • Additional benefit: Static frontend build eliminates server-side rendering delays

This means:

  • TwinSpires engineers already understand the split model.
  • No architectural learning curve (just new frameworks/languages).
  • Easier migration path (can migrate frontend and backend independently).
  • Performance improvement: Static frontend build + CDN provides better initial load performance than server-side rendering.

8. Summary: Why split architecture with static frontend build

The key advantages of moving from monolithic Next.js to a split architecture with static frontend build:

  1. Performance: Static files served from CDN eliminate server-side rendering delays, drastically improving TTFB and First Contentful Paint.

  2. Separation of concerns: UI and domain logic evolve independently.

  3. Flexibility: Choose the best backend runtime per use case (Node/TS or Python) without touching the frontend.

  4. Scalability: Independent scaling, deployments, and security postures for API and UI.

  5. Team organization: Clear ownership for frontend and backend teams (aligns with TwinSpires' model).

  6. Future work: Easier to introduce Python-heavy quantum/AI services alongside existing Node/TS services.

  7. Migration path: Can migrate frontend and backend independently, reducing risk.

  8. Alignment: Matches TwinSpires' proven Spring Boot + Angular split architecture, with added performance benefits from static builds.