MK
Manjit KumarFull Stack Developer
Contact
Web Development

Building a Modern Web Application: From Frontend to Backend

17 October 202616 min read3 views
Building a Modern Web Application: From Frontend to Backend
#Webdevelopment#Fullstack#React#Nodejs#Postgresql#Softwarearchitecture

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

text
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:

text
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.md

What belongs where?

DirectoryResponsibility
components/Reusable UI elements
features/Feature-specific UI and logic
lib/api.tsShared 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:

prisma
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:

MethodEndpointPurpose
POST/api/auth/loginSign in
POST/api/auth/logoutEnd the current session
GET/api/auth/meGet the authenticated user
GET/api/projectsList the user's projects
POST/api/projectsCreate a project
GET/api/projects/:idGet one accessible project
PATCH/api/projects/:idUpdate an accessible project
DELETE/api/projects/:idDelete an accessible project

Each endpoint should have a clear request format, response format, and error behavior.

For example, creating a project could accept:

json
{ "name": "Portfolio Redesign" }

A successful response could return:

json
{ "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 OK for a successful read or update.
  • 201 Created when a resource is created.
  • 204 No Content when an operation succeeds without a response body.
  • 400 Bad Request for invalid request data.
  • 401 Unauthorized when valid authentication is missing.
  • 403 Forbidden when an authenticated user lacks permission.
  • 404 Not Found when a resource does not exist or should not be exposed.
  • 409 Conflict for a genuine resource conflict.
  • 500 Internal Server Error for 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:

text
Incoming Request | v Authentication Middleware | v Route and Input Validation | v Controller | v Service | v Database | v HTTP Response

Each 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:

typescript
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:

typescript
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:

typescript
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.

typescript
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:

typescript
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:

  1. Store password hashes using a suitable password-hashing algorithm, such as Argon2id.
  2. Verify the supplied password against the stored hash.
  3. Generate a cryptographically secure random session token.
  4. Store a hash of that token with its user ID and expiry.
  5. Send the raw token through a cookie configured with HttpOnly, Secure, and an appropriate SameSite value.
  6. Validate the session on protected requests.
  7. 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:

json
{ "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.

text
Internet | v HTTPS / Domain | +---------+----------+ | | v v Frontend Hosting Backend Hosting Static Assets / UI Node.js API | | Private or TLS v Managed PostgreSQL

A practical deployment checklist:

  1. Build the frontend for production.
  2. Deploy the backend with a supported Node.js runtime.
  3. Provision a managed PostgreSQL database.
  4. Configure environment variables and secrets on the server.
  5. Apply database migrations through the deployment process.
  6. Configure HTTPS, CORS, cookie settings, and allowed origins.
  7. Add health checks and structured application logs.
  8. Test authentication and core API flows in the production environment.
  9. Configure database backups and verify that recovery is possible.
  10. 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.

Share

© 2026 Manjit Kumar - All rights reserved

Last updated

With Love, By Manjit.

;