SaaS application development
Next.js SaaS Development
Multi-tenant SaaS products on the App Router - tenancy and authorisation designed before the first screen, billing that survives an audit, and a dashboard that stays on the server.
A SaaS product is not a website with a login. Three decisions made in the first fortnight - how tenancy works, where authorisation lives, and how billing state reaches the application - determine what the next two years cost, and all three are expensive to reverse once there is production data.
We build these, and we start with those three.
Tenancy, decided before the first screen
There are three models and the right one depends on your customers, not on fashion.
| Model | Isolation | When it is right |
|---|---|---|
| Shared tables, tenant column | Enforced in code | Most products. Cheapest to run and to migrate. |
| Schema per tenant | Enforced by the database | Regulated customers, per-tenant export or restore. |
| Database per tenant | Total | Few, large, contractually isolated customers. |
The shared model is right most of the time, and its one real risk is a query that forgets the tenant filter. That is not a discipline problem to be solved with code review - it is solved with row-level security in the database, so a missing filter returns nothing instead of returning someone else's data.
Authorisation at the data, not at the route
Middleware deciding who may see a page is a gate, and gates are useful. They are not authorisation. Server Actions and Route Handlers are callable directly, so a check that lives only in middleware is a check the endpoint does not have.
Every query carries the tenant and the actor. Every mutation re-derives the permission from the session rather than trusting an argument. The reasoning is the same one in Server Actions and the three things people get wrong, and in a multi-tenant product the failure mode is not a bug, it is one customer reading another's data.
Billing that survives an audit
Stripe is the default and the integration is not the hard part. The hard part is that your application and Stripe hold the same fact - what this customer is entitled to - and they will disagree.
We treat Stripe as the source of truth and the application's copy as a cache rebuilt from webhooks, with the webhook handler idempotent and signature- checked. Entitlements are read from your own database on every request, because calling Stripe in the request path makes your product's availability depend on theirs.
Proration, trials, seat changes and failed payments are each a state the UI has to express. Deciding what a past-due account can still do is a product decision, and we will ask for it rather than inventing one.
The dashboard problem
SaaS dashboards are where App Router applications get slow. The instinct is a
use client at the top because there are charts and filters, and then the
entire dashboard tree ships to the browser.
The tables, headers, navigation and data fetching stay on the server. The chart, the filter and the date picker are client islands. URL state carries the filters, which makes a filtered view shareable and the back button correct. And no page waits for its slowest widget - each streams behind its own boundary, which is the point of loading.tsx not being a spinner.
What we do not do
We do not start from a SaaS boilerplate. They encode tenancy, auth and billing decisions made for a different product, and unpicking those later costs more than the fortnight they saved.
We also do not build the admin panel first. It is the part founders most enjoy specifying and the part customers never see.
