Building a Modern Web Application: From Frontend to Backend

Building a Modern Web Application: From Frontend to Backend
A modern web application is more than a collection of pages and API endpoints. Its frontend, backend, database, authentication system, and deployment environment must work together as one system.
Consider a simple project management application. A user signs in, creates a project, adds tasks, and views the saved data later.
What happens when the user clicks Create Project?
The frontend collects the input. The backend verifies the request and the user's permissions. The database stores the project. The backend returns a response, and the frontend updates the interface.
Understanding this complete flow is more valuable than knowing how to use each technology in isolation.
In this guide, we'll design a practical full-stack application using:
- Frontend: React with TypeScript
- Backend: Node.js with Express
- Database: PostgreSQL
- Database access: Prisma ORM
- Authentication: Secure server-managed sessions using an HTTP-only cookie
- Deployment: A frontend hosting platform, a backend application server, and a managed PostgreSQL database
These are example choices, not mandatory technologies. The same architecture principles apply to Next.js, Fastify, other databases, and different hosting platforms.
1. Understand the Architecture
Start with three main components.
Frontend
The frontend is responsible for the user interface and user interactions.
Examples include:
- Login and registration forms.
- Project lists and task tables.
- Form validation for immediate feedback.
- Loading states, error messages, and navigation.
- Sending requests to the backend.
The frontend should not be trusted to enforce security. Users can modify requests, bypass interface restrictions, and call API endpoints directly.
Backend
The backend handles application rules and access control.
It is responsible for:
- Receiving HTTP requests.
- Validating input.
- Authenticating users.
- Checking permissions.
- Executing application logic.
- Reading and writing database records.
- Returning appropriate HTTP responses.
For example, the frontend may hide the Delete Project button from a user who does not own the project. The backend must still verify ownership when a delete request arrives.
Database
The database stores information that must persist beyond a single browser session or server restart.
For our project management application, PostgreSQL could store:
- Users
- Sessions
- Projects
- Tasks
The database should enforce appropriate constraints and relationships. Application code alone is not enough to protect data integrity.
Architecture diagram
User
|
v
+--------------------+
| Browser / Frontend |
| React + TypeScript |
+--------------------+
|
HTTPS API request
|
v
+--------------------+
| Backend API |
| Node.js + Express |
+--------------------+
|
+---------+----------+
| |
v v
+----------------+ +----------------+
| Auth, Validation| | Business Logic |
| & Authorization| | Services |
+----------------+ +----------------+
|
v
+--------------------+
| PostgreSQL |
| Persistent Data |
+--------------------+The frontend communicates with the backend through a defined API contract. The backend owns the rules for accessing and changing persistent data.
2. Organize the Project Before It Grows
A small project can begin with a few files, but keeping frontend and backend responsibilities separate makes the code easier to maintain.
One possible monorepo structure is:
project-manager/
├── apps/
│ ├── web/
│ │ ├── src/
│ │ │ ├── components/
│ │ │ ├── features/
│ │ │ │ ├── auth/
│ │ │ │ └── projects/
│ │ │ ├── lib/
│ │ │ │ └── api.ts
│ │ │ ├── hooks/
│ │ │ ├── types/
│ │ │ └── main.tsx
│ │ └── package.json
│ │
│ └── api/
│ ├── src/
│ │ ├── routes/
│ │ ├── controllers/
│ │ ├── services/
│ │ ├── middleware/
│ │ ├── lib/
│ │ ├── app.ts
│ │ └── server.ts
│ ├── prisma/
│ │ └── schema.prisma
│ └── package.json
│
├── package.json
├── .gitignore
└── README.mdWhat belongs where?
| Directory | Responsibility |
|---|---|
components/ | Reusable UI elements |
features/ | Feature-specific UI and logic |
lib/api.ts | Shared frontend API client |
routes/ | API endpoint definitions |
controllers/ | HTTP request and response handling |
services/ | Application rules and workflows |
middleware/ | Authentication, validation, and error handling |
prisma/ | Database schema and migrations |
This structure is a starting point, not a rule that every project must follow. Avoid creating empty layers that add no value to a small application.
3. Design the Database Around Real Relationships
Before implementing endpoints, define the data model.
A project belongs to a user, and a task belongs to a project. These relationships should be represented in the database.
A simplified Prisma schema could look like this:
model User {
id String @id @default(cuid())
email String @unique
name String
projects Project[]
sessions Session[]
createdAt DateTime @default(now())
}
model Project {
id String @id @default(cuid())
name String
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
tasks Task[]
createdAt DateTime @default(now())
@@index([ownerId, createdAt])
}
model Task {
id String @id @default(cuid())
title String
completed Boolean @default(false)
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([projectId, createdAt])
}
model Session {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
}This example assumes that session tokens are generated securely and only their hashes are stored in the database. The raw session token is sent to the browser in a secure cookie.
The schema also defines indexes for common queries, such as listing projects by owner. Indexes should be based on actual query patterns and measured performance.
After defining or changing the schema, generate and review a database migration. Apply migrations through a controlled deployment process rather than relying on an automatic schema reset in production.
4. Define the API Contract
The frontend and backend need an agreed way to communicate.
For the project management application, the API might expose these endpoints:
| Method | Endpoint | Purpose |
|---|---|---|
POST | /api/auth/login | Sign in |
POST | /api/auth/logout | End the current session |
GET | /api/auth/me | Get the authenticated user |
GET | /api/projects | List the user's projects |
POST | /api/projects | Create a project |
GET | /api/projects/:id | Get one accessible project |
PATCH | /api/projects/:id | Update an accessible project |
DELETE | /api/projects/:id | Delete an accessible project |
Each endpoint should have a clear request format, response format, and error behavior.
For example, creating a project could accept:
{
"name": "Portfolio Redesign"
}A successful response could return:
{
"data": {
"id": "project_123",
"name": "Portfolio Redesign",
"createdAt": "2026-10-18T10:30:00.000Z"
}
}The identifier and timestamp above are illustrative.
Use appropriate HTTP status codes:
200 OKfor a successful read or update.201 Createdwhen a resource is created.204 No Contentwhen an operation succeeds without a response body.400 Bad Requestfor invalid request data.401 Unauthorizedwhen valid authentication is missing.403 Forbiddenwhen an authenticated user lacks permission.404 Not Foundwhen a resource does not exist or should not be exposed.409 Conflictfor a genuine resource conflict.500 Internal Server Errorfor unexpected server failures.
The exact error policy should be consistent across the API.
5. Build the Backend Request Flow
A maintainable backend separates HTTP handling from application logic.
The request flow is:
Incoming Request
|
v
Authentication Middleware
|
v
Route and Input Validation
|
v
Controller
|
v
Service
|
v
Database
|
v
HTTP ResponseEach layer has a distinct responsibility.
- Middleware checks authentication and other cross-cutting concerns.
- Validation verifies that input has the expected shape.
- Controller translates HTTP input into an application call.
- Service implements the actual use case and authorization rules.
- Database layer reads and writes persistent records.
For a very small API, some of these responsibilities can share a file. The important part is keeping security checks and application rules clear.
Example: Creating a project
A service using Prisma might look like this:
import { prisma } from "../lib/prisma";
type CreateProjectInput = {
name: string;
};
export async function createProject(
userId: string,
input: CreateProjectInput
) {
return prisma.project.create({
data: {
name: input.name,
ownerId: userId,
},
select: {
id: true,
name: true,
createdAt: true,
},
});
}Notice that userId comes from the authenticated server-side identity, not from a client-supplied ownerId.
Otherwise, a malicious user could attempt to create a project under another user's account.
This service should only be called after the request has passed authentication and input validation. In a real implementation, validate the project name, handle expected database errors, and return a consistent API response.
Validate input at the boundary
Never assume that TypeScript types protect an HTTP endpoint at runtime. A client can send any JSON it wants.
For example, a Zod schema could validate the incoming project name:
import { z } from "zod";
export const createProjectSchema = z.object({
name: z.string().trim().min(1).max(100),
});Then parse the request body before calling the service:
const input = createProjectSchema.parse(req.body);In production, catch validation errors and convert them into a consistent 400 Bad Request response. Do not expose raw stack traces or internal database errors to the client.
6. Connect the Frontend to the API
The frontend should not duplicate database logic. It sends requests to the backend and renders the results.
A small API helper can centralize the base URL, credentials, and response handling.
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? "/api";
export async function apiRequest<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const response = await fetch(
`${API_BASE_URL}${path}`,
{
...options,
credentials: "include",
headers: {
"Content-Type": "application/json",
...options.headers,
},
}
);
if (!response.ok) {
throw new Error(
`Request failed with status ${response.status}`
);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}This is a minimal example. A production API client should also parse structured error responses and handle cases such as non-JSON responses appropriately.
The VITE_ environment variable is public client-side configuration. Never put database passwords, private API keys, or signing secrets in frontend environment variables.
Now create a project:
type Project = {
id: string;
name: string;
createdAt: string;
};
type CreateProjectResponse = {
data: Project;
};
export async function createProject(
name: string
): Promise<Project> {
const result =
await apiRequest<CreateProjectResponse>(
"/projects",
{
method: "POST",
body: JSON.stringify({ name }),
}
);
return result.data;
}The frontend calls this function when the user submits the form. It can display a loading state, show a validation error, and update the project list after a successful response.
For larger applications, use a server-state library such as TanStack Query or RTK Query to manage fetching, caching, retries, and invalidation. Choose one that fits the application's existing state-management approach rather than adding multiple overlapping libraries.
7. Understand One Complete Request
Let's trace the Create Project operation from beginning to end.
Step 1: The user submits the form.
The frontend checks basic input requirements and sends a POST /api/projects request over HTTPS. The browser includes the session cookie when configured to do so.
Step 2: The backend authenticates the request.
The server validates the session, checks its expiry, and identifies the user. If authentication fails, the API returns 401 Unauthorized.
Step 3: The backend validates the input.
The request body is checked against the project schema. An empty or excessively long project name is rejected before reaching the database.
Step 4: The service executes the operation.
The service uses the authenticated user's ID when creating the project. It does not trust an owner ID supplied by the browser.
Step 5: PostgreSQL stores the record.
The database enforces constraints and persists the project. The ORM returns the selected fields needed for the response.
Step 6: The backend returns the result.
The API sends a 201 Created response containing the new project's ID, name, and creation time.
Step 7: The frontend updates the interface.
The application adds the new project to the displayed list or invalidates and refetches the relevant query. It also handles failures without losing the user's input unnecessarily.
This flow shows why frontend, backend, and database code must agree on the request shape, identity, response format, and error behavior.
8. Authentication Is More Than a Login Form
A login form does not secure an application by itself. The backend must verify credentials and protect every private operation.
For a browser-based application using server-managed sessions, a common approach is:
- Store password hashes using a suitable password-hashing algorithm, such as Argon2id.
- Verify the supplied password against the stored hash.
- Generate a cryptographically secure random session token.
- Store a hash of that token with its user ID and expiry.
- Send the raw token through a cookie configured with
HttpOnly,Secure, and an appropriateSameSitevalue. - Validate the session on protected requests.
- Invalidate the session when the user logs out or it expires.
HttpOnly prevents ordinary client-side JavaScript from reading the cookie. Secure restricts cookie transmission to HTTPS. SameSite helps control cross-site cookie sending, but it is not a complete substitute for CSRF protection.
If the frontend and API are on different origins, configure CORS for the exact allowed origin and enable credentials where required. Do not combine credentialed requests with a wildcard allowed origin. Review cookie settings and CSRF defenses for the actual deployment topology.
The backend must also verify resource ownership. Being logged in does not mean a user can access every project by changing the project ID in a URL.
9. Handle Errors and Loading States
A reliable application handles failure as part of normal operation.
Frontend states
For a project-list page, account for:
- Loading: data is being fetched.
- Success: projects are available.
- Empty: no projects exist yet.
- Error: the request failed.
- Unauthorized: the user must sign in again.
These states help users understand what is happening instead of seeing a blank screen.
Backend errors
Use centralized error handling to keep response formats consistent.
For example:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Project name is required"
}
}This is an illustrative response format.
Return useful messages for expected failures, but do not send stack traces, SQL details, session tokens, or secret configuration to the browser. Log unexpected errors on the server with enough context to investigate them safely.
Also distinguish retryable failures from permanent ones. Retrying a read request may be reasonable; blindly retrying a create operation can produce duplicate records if the first request succeeded but its response was lost.
10. Test the Boundaries Between Components
Testing individual functions is useful, but many full-stack bugs occur where components meet.
A practical testing strategy includes:
Unit tests
- Validate business rules.
- Test input parsing and utility functions.
- Test permission decisions.
API integration tests
- Verify status codes and response formats.
- Confirm invalid input is rejected.
- Check that unauthenticated requests cannot access private resources.
- Verify one user cannot read or delete another user's project.
Frontend tests
- Check loading, success, empty, and error states.
- Verify forms display validation errors.
- Ensure successful operations update the interface.
End-to-end tests
- Sign in as a test user.
- Create a project.
- Verify it appears in the project list.
- Sign out and verify protected operations require authentication.
A particularly valuable test is the authorization boundary: create two users and confirm that user A cannot access user B's project by guessing or changing its ID.
11. Deploy the Application Without Mixing Responsibilities
A typical production deployment separates the frontend, backend, and database into their appropriate environments.
Internet
|
v
HTTPS / Domain
|
+---------+----------+
| |
v v
Frontend Hosting Backend Hosting
Static Assets / UI Node.js API
|
| Private or TLS
v
Managed PostgreSQLA practical deployment checklist:
- Build the frontend for production.
- Deploy the backend with a supported Node.js runtime.
- Provision a managed PostgreSQL database.
- Configure environment variables and secrets on the server.
- Apply database migrations through the deployment process.
- Configure HTTPS, CORS, cookie settings, and allowed origins.
- Add health checks and structured application logs.
- Test authentication and core API flows in the production environment.
- Configure database backups and verify that recovery is possible.
- Monitor errors, latency, resource usage, and database connections.
The frontend's public API URL is configuration, not a secret. Database credentials and session-related secrets belong in the backend environment or a dedicated secrets manager.
Avoid assuming that a successful build means a successful deployment. Production configuration, database connectivity, migrations, permissions, and cookie behavior must all be verified.
12. Keep the Architecture as Simple as the Requirements Allow
You do not need microservices, Kubernetes, Redis, Kafka, and multiple databases just to build a modern web application.
Start with a frontend, a backend API, and a suitable database. Establish clear interfaces, validate input, enforce authorization, and test important behavior.
Introduce additional infrastructure when there is a specific need:
- Add Redis when caching or another in-memory capability solves a demonstrated problem.
- Add a job queue when work needs reliable asynchronous processing.
- Separate services when independent deployment, ownership, or scaling requirements justify the operational cost.
- Add orchestration when deployment and workload requirements warrant it.
The right architecture is not the one with the most components. It is the one that meets the requirements reliably while remaining understandable and maintainable.
Conclusion
A modern web application works because its components have clear responsibilities and communicate through well-defined boundaries.
The frontend handles user interaction. The backend enforces application rules and access control. The database maintains persistent data and integrity. Tests verify that the pieces work together, while deployment and monitoring keep the application reliable in production.
When you build your next project, trace one important operation from the user's click to the database and back. Make sure you understand every boundary in that flow.
That is the foundation of full-stack engineering: not simply knowing several technologies, but understanding how to make them work together as one system.