Next.js & full-stack · Published · 9 min

Next.js architecture for a production SaaS: the decisions I make on every product

What separates a Next.js project that holds from one that gets rewritten after a year comes down to about ten decisions. Here are mine, as applied on Atamatax, Horvera, Wabiro, SponsorsMetrics — and on this site.

  • Next.js
  • Architecture
  • SaaS

Next.js lets one person ship a complete web product. It also lets one person ship a demo that looks like a product. The difference is not in the framework; it is in about ten decisions taken — or not — at the start. Here are the ones I apply on every product I operate, and on this site, whose rendered source you can inspect.

The data model before the screens

The first week of a product produces no screens. It produces a Postgres schema: the entities, who owns them, what is immutable, what is versioned, the constraints and the indexes. Migrations are versioned in the repo and run in the pipeline. It is the only part of the product the rest cannot work around, so the only part it is dangerous to leave vague.

On Horvera, airports, airlines and their scope are reference data; the rules engine and the programmatic pages read them from the same source, which guarantees a page never promises what the engine would refuse. That consistency is a schema decision, not a code decision.

Server Components by default, client by exception

With the App Router, a component renders on the server unless it declares otherwise. I keep that rule strict: a page reads its data where it displays it, sends HTML search engines can read without execution, and ships JavaScript only for real interactivity. This site is the extreme example — every component is a Server Component, and the interactive layer is a deferred vanilla script of a few kilobytes.

The benefit is not only performance. A Server Component cannot leak a secret to the browser, cannot be called with data the server has not validated, and tests like a function. The client component remains necessary — a calculator, an interactive test as on Wabiro — but it is the exception you justify.

Forms are server actions — and work without JavaScript

A form is a `<form>` bound to a server action. It works without JavaScript: the submit is a POST, the action validates the fields with Zod, applies a per-IP rate limit, checks a honeypot field, sends what it has to send, then redirects to the result URL the page renders as a state. That is exactly how this site’s contact form works, and the Build School forms.

There is no API layer to maintain for every screen, no loading state to synchronise, no double submit possible without it being handled on the server. When JavaScript is present, Next.js progressively enhances the form; when it is not, nothing breaks.

Route handlers for what a third party calls

Stripe webhooks, integrations and public endpoints are route handlers — not actions. They verify a signature, are idempotent (a webhook received twice does not bill twice) and log what they did. On this site, the Build School’s Stripe webhook verifies the HMAC signature with Node’s crypto module, without an SDK: thirty lines you can read in full.

Billing state lives in the database, written by the webhook, never inferred on the client. A page that shows “subscription active” reads the database; it does not ask Stripe.

Business logic in pure, versioned modules

An eligibility rule, a commission calculation, a scoring: all of it lives in pure functions, with no dependency on React or the request, tested against real scenarios and versioned. Horvera’s rules engine produces every answer with its trace and sources; Wabiro’s scoring is a module that runs in the browser and also produces the method pages; Atamatax’s tax situation engine shows its assumptions and confidence.

The practical consequence: a rule that changes is modified in one place, with a version, and past results remain explainable. An AI feature, when justified, slots into this frame as one more typed function — I cover that in When should a SaaS use AI.

Authentication and authorisation on the server

Sessions are checked on the server, on every data read — not only at the entrance of a page. Permissions are modelled in the database (account, role, resource) and enforced in the queries. An interface that hides a button is not authorisation. The Next.js proxy routes and protects route groups; it does not replace the check inside every data access.

Caching and rendering, page by page

Every page has a chosen rendering strategy: static when the content allows it, dynamic when it depends on the user, revalidated when data changes. The trap is inheriting a default without knowing it — a marketing page rendered on every request, or a dashboard cached between two users. I note the strategy of every route in the code, and I verify it in the response headers in production.

SEO as an output of the architecture

Metadata, sitemap, canonicals, hreflang and JSON-LD are derived from the same data as the pages. On this site, the sitemap reads the list of articles, projects and services; locale routing happens in the proxy, which rewrites `/en/...` to the French route with the locale as a parameter; structured data is generated by functions that read the displayed content. Nothing is written twice, so nothing can contradict itself.

On Horvera, the programmatic pages — airline, airport, route, disruption type, jurisdiction — are generated from the engine’s reference data. SEO is not a layer added on top; it is what an architecture produces when the data is in the right place.

Observability from the first deployment

Structured logs, error alerts, product events (PostHog or Vercel Analytics), an audit log on sensitive actions — wired at the first deployment, not at the first incident. Deployment is on Vercel with a preview per branch: the Friday demos happen on those previews, with the real pre-production environment variables.

What I do not do

  • No microservices, no message queue, no distributed cache before a real bottleneck justifies them. A well-indexed Postgres goes a long way.
  • No home-made abstraction on top of Next.js. The standard App Router structure is what another developer will know how to read.
  • No SDK when a signed REST call does the same thing in thirty readable lines.
  • No client rendering for pages search engines have to read.

In one line

Data first, server by default, forms without JavaScript, signed and idempotent webhooks, pure and versioned business logic, authorisation in the queries, chosen caching, derived SEO, observability from the start. That is what I build when handed a Next.js application — and what you can verify on the products at the bottom of this page.

Built this way

FIELD NOTES · À APPLIQUER

The matching service

Need this implemented in a production product?

Check project fit

Six questions, an immediate orientation, then a free scope review.