Architecture
React and Node.js Architecture: Where Responsibilities Belong
By Vivek Jalondhara · 6 min read
Published
Design a React and Node.js SaaS application with clear boundaries for UI state, business rules, database access, and background work.
A React frontend and a Node.js backend describe the runtimes, but say little about the architecture. The difficult decisions are who owns a rule, where a state change becomes authoritative, and what happens when two people edit the same record. Those decisions should be visible before the folder structure grows.
Consider an illustrative CRM record editor related to the SmackDab project domain. A user opens a record, changes its title, and saves it while another teammate may be editing it. The design below is a starting point for that workflow, not a claim about the CRM's production service layout.
Start with modules before adding services
For this editor, a reasonable starting design is one Node.js application with separate modules for records, permissions, and activity history. Each module exposes the operations other modules need. This is often called a modular monolith: boundaries are explicit in the code, while deployment remains shared.
A folder called services is not a boundary by itself. If every handler can update every table and bypass the record module's rules, the application still has tangled ownership. Describe which module may change a record and which operation callers must use. A shared deployment makes this arrangement easier to change while the product requirements are still becoming clear.
Keep the editing draft separate from saved server data
React needs local state for an unsaved title, the selected tab, and whether a save is in progress. The loaded record is server data with a different owner and lifetime. If a background refresh replaces the editor's draft on every fetch, a user's unsaved work can disappear even though the data-fetching code is behaving as configured.
Choose when the draft starts, when it resets, and what happens if newer server data arrives. Derive simple display values instead of keeping duplicate state that can disagree. In this example, a dirty indicator can compare the draft title with the version originally loaded into the editor; it does not need a second independently maintained flag.
Put authorization and business rules on the server
The HTTP boundary establishes the caller, checks access to the target record, and validates the request shape and size. A disabled save button can improve the interface, but a direct request can bypass it. The record module then applies the update policy and returns an outcome the client understands.
Keep the authority to choose the workspace in trusted authentication and authorization context. A workspace identifier received in a URL or body can identify a requested resource; it does not prove membership. Database queries must be scoped to the authorized record. Sharing TypeScript contracts between frontend and backend is useful, but runtime validation is still necessary at the network boundary.
Make a concurrent edit a designed outcome
Load a record with a version, then submit that expected version with the change. The server can perform an update conditioned on both the record identity and the expected version, incrementing the version in the same operation. If no eligible row is updated, the application must determine whether the record changed, disappeared, or is no longer accessible under its policy.
Do not first check the version in one query and then update unconditionally in another; another edit can occur between them. A version conflict should preserve the user's draft and offer a way to compare or reload. The example response union makes that distinction visible to the UI, while the database operation is responsible for enforcing it.
Decide which side effects must commit together
If a record update and its audit entry must always agree, write them in one database transaction. An email or webhook belongs to a different failure boundary: a database rollback cannot unsend a message. Decide whether a delayed notification is acceptable and how an unsuccessful delivery will be retried.
For delivery that must survive an application restart, a possible design records pending work in the same transaction as the update, then lets a dispatcher deliver it. That pattern adds its own retry and duplicate-handling responsibilities. A best-effort notification may need less machinery; the product's delivery requirement should determine the choice.
Move expensive work only when the workload calls for it
Node.js can coordinate asynchronous requests, but CPU-heavy synchronous work still occupies its event loop. A large export or document-processing operation may need a separate execution path. Measure the time spent computing, waiting for the database, and waiting on external services before treating every slow request as the same problem.
A durable job and a worker thread solve different concerns. A job records work that should survive a restart; a worker thread can move suitable CPU work away from the main JavaScript thread. Neither automatically supplies the other. Start from the required lifetime and resource profile, then choose the smallest mechanism that meets both.
Use an extraction trigger for future services
Splitting a module into a service can become useful when it needs independent deployment, resource scaling, or ownership. It also introduces network failures and distributed data changes. A small API boundary inside one application gives you a place to make that split if evidence later supports it.
Before adding a service, explain the constraint that the existing deployment cannot meet and how the new boundary resolves it. For the CRM editor, verify saved updates, invalid titles, authorization failures, concurrent edits, and retained drafts first. Those behaviors are more useful evidence of architecture quality than a diagram containing many runtime boxes.
Make save outcomes explicit in the interface
The server response distinguishes a successful update from a version conflict. The React UI can then offer a specific recovery action. Validate incoming JSON before treating it as this union; the type itself does not enforce the API contract.
Illustrative example · TypeScript · Simplified for explanation
type SaveResult =
| { kind: "saved"; version: number }
| { kind: "conflict"; latestVersion: number }
| { kind: "invalid"; field: "title"; message: string };
function saveNotice(result: SaveResult): string {
switch (result.kind) {
case "saved":
return "Changes saved.";
case "conflict":
return "This record changed. Review the latest version before saving.";
case "invalid":
return result.message;
}
}
// Authentication, access failures, and network errors are handled
// separately by the API client. A retry does not overwrite a conflict.- React submits draft and expected version
- API validates access and input
- Database checks version during update
- UI shows saved, invalid, or conflict state
Conceptual flow for the example above.
Tradeoff to keep in mind
A version field only helps if the database operation checks and changes it atomically. The UI must keep the user's draft when a conflict occurs; automatically fetching fresh data and overwriting the editor defeats the recovery path.