For a small team building a business SaaS product, Next.js and PostgreSQL are a sensible starting point. Keep the application in one codebase, separate business rules from request handlers, and add a worker when tasks must survive a browser request. This gives you room to change the product without operating a fleet of services before you have customers.
Start with the product's transactions
Sketch the actions that create business value before drawing infrastructure boxes. For a workspace product, those actions might be accepting an invitation, creating a project, assigning work, and purchasing a subscription. Each needs a defined actor, permission check, database change, and failure response.
The useful architectural unit is a business operation. An operation such as acceptInvitation should own its validation and transaction. It should not be scattered across a React form, a webhook handler, and an administrator script with slightly different rules.
A reasonable initial system has four responsibilities:
- Next.js renders the application and accepts user requests.
- An authentication system establishes identity and maintains sessions.
- PostgreSQL stores business records and enforces data constraints.
- A worker executes durable asynchronous tasks, if the product needs them.
Object storage can hold uploaded files. Email and payments can remain external services. These are boundaries with explicit contracts, rather than reasons to introduce internal microservices.
Keep business logic behind the interface
In the App Router, Server Components can read data on the server. The current Next.js backend guide recommends accessing a data source directly from those components instead of calling your own Route Handler over HTTP. Route Handlers remain useful for external clients, provider callbacks, and browser requests that need an HTTP endpoint. Next.js backend guidance explains these distinctions.
Both paths should call the same application services. A server-rendered project page and an export endpoint can share a project query that resolves the tenant and verifies membership. They should return only the fields the caller needs.
An illustrative directory structure is:
app/
dashboard/
api/webhooks/
src/
domains/projects/
domains/memberships/
domains/billing/
infrastructure/database/
infrastructure/email/
workers/
exports/
This layout does not require a dependency injection framework. Its purpose is to make it easy to find the rules and hard to bypass them. Keep database access on the server, and treat everything entering from a browser as untrusted input.
Use PostgreSQL for the relationships
Model users, organizations, memberships, projects, and subscriptions explicitly. An organization and a user are different entities, even when the first customer has one person. A membership connects them and carries the role within that organization.
Use foreign keys for references and unique constraints for business rules such as one membership per user and organization. A pre-insert query is useful for friendly feedback, but concurrent requests can both pass that query. The database constraint decides which write succeeds. PostgreSQL documents these mechanisms in its constraints guide.
Put flexible peripheral attributes in JSONB when needed. Keep fields used for authorization, money, joins, and common filters in well-defined columns. The presence of a few customizable fields does not require a second database.
Design indexes around actual access paths. A project listing filtered by organization and sorted by creation time has different needs from an administrator lookup by external payment identifier. Measure query plans with realistic data before adding speculative indexes to every column.
Make tenant isolation a first release requirement
Derive the active organization from a verified session and an authorized membership. A tenant ID in a URL identifies the requested organization; it does not establish permission to access it.
Every tenant-owned operation must carry tenant context. Include this in file paths, cache keys, background jobs, exports, and audit records. A carefully filtered SQL query is insufficient if a shared cache later returns another organization's result.
PostgreSQL row-level security can provide another enforcement layer. Its official documentation explains that table owners normally bypass policies and privileged roles may bypass them entirely. Test using the actual application role. A policy tested only through a privileged administration connection has not demonstrated isolation.
Read the dedicated multi-tenant architecture guide before choosing an isolation model. Shared tables are often practical, while contractual isolation or tenant-specific recovery can justify a stronger boundary.
Keep slow work outside the request
A user action should finish once the required state is safely committed. Sending an email, generating a large export, or synchronizing an external system can happen afterward when those tasks do not define the transaction itself.
Do not start an untracked promise and assume the hosting environment will keep running it. Execution lifetime depends on the deployment. Persist the work, then let a worker claim it. The user should receive a job identifier and a visible status, rather than a spinner that hides whether anything was saved.
If committing a record and publishing a job must succeed together, use a transactional outbox: save an event beside the record in the same database transaction, then publish it separately. Consumers still need duplicate protection. The background jobs guide covers retries and rate limits.
Treat deployment and recovery as architecture
Use separate development, staging, and production environments. Keep secrets out of client bundles. Run migrations through a controlled deployment step and prefer changes that allow the previous application version to keep working during rollout.
For database connections, account for every application instance and worker. A small connection pool multiplied by many instances can still exhaust the database. Match the connection strategy to the hosting model and the provider's supported pooling behavior.
Before launch, rehearse restoring a backup into an isolated environment. Check records, file references, and authentication behavior. A backup existing in a dashboard is different from demonstrating that the business can resume operation.
Capture request errors, slow queries, failed jobs, and important business transitions. Logs should identify a request and organization without recording session tokens or unnecessary personal data. The pre-launch checklist helps turn those responsibilities into acceptance criteria.
Extract services when evidence supports it
A separate backend becomes useful when independently deployed clients, long-lived connections, different scaling needs, or team ownership demand it. A worker may warrant its own deployment before any API does.
Avoid extracting a service solely because a domain folder has grown large. A service introduces network failures, deployment coordination, and data ownership decisions. First improve the module boundary inside the existing application.
For an MVP, the goal is a system the team can explain, operate, and change. If you want a second opinion on those boundaries, Apex's product development team works on MVP architecture and delivery.