Skip to content

React Final Project — Build a Full-Stack Application

DodaTech 11 min read

In this tutorial, you'll learn about React Final Project. We cover key concepts, practical examples, and best practices.

What You'll Learn

Build a complete full-stack React application from scratch — setup a project with a backend, design a component hierarchy, manage state, integrate authentication, and deploy to production on Vercel or Netlify.

Why It Matters

Tutorials teach you pieces. A full-stack project teaches you how those pieces fit together — frontend, backend, database, authentication, and deployment. Employers and clients want to see that you can ship a complete product, not just a component.

Real-World Use

A task manager that stores data in a database, authenticates users with JWT, and deploys to the cloud — exactly the architecture used by project management tools like Trello, Asana, and Notion.

Project Overview

We will build a Task Manager application with the following features:

  • User registration and login (JWT)
  • Create, read, update, and delete tasks
  • Mark tasks as complete
  • Filter tasks by status (all, active, completed)
  • Responsive design
  • Deployed to Vercel (frontend) and Render (backend)

Project Setup

We use Vite + React for the frontend and Node.js with Express.js for the backend.

# Create frontend with Vite
npm create vite@latest task-manager -- --template react
cd task-manager
npm install react-router-dom @tanstack/react-query axios

# Create backend directory
mkdir server
cd server
npm init -y
npm install express cors dotenv jsonwebtoken bcryptjs
npm install prisma @prisma/client
npx prisma init

Folder Structure

task-manager/
├── public/
├── src/
│   ├── components/          # Reusable UI components
│   │   ├── Layout.jsx
│   │   ├── Navbar.jsx
│   │   ├── TaskCard.jsx
│   │   ├── TaskForm.jsx
│   │   ├── TaskList.jsx
│   │   └── ProtectedRoute.jsx
│   ├── pages/               # Route pages
│   │   ├── Home.jsx
│   │   ├── Login.jsx
│   │   ├── Register.jsx
│   │   └── Dashboard.jsx
│   ├── hooks/               # Custom hooks
│   │   ├── useAuth.js
│   │   └── useTasks.js
│   ├── context/             # Global state
│   │   └── AuthContext.jsx
│   ├── api/                 # API client
│   │   └── client.js
│   ├── App.jsx
│   └── main.jsx
├── server/
│   ├── prisma/
│   │   └── schema.prisma
│   ├── routes/
│   │   ├── auth.js
│   │   └── tasks.js
│   ├── middleware/
│   │   └── auth.js
│   └── index.js
├── .env
└── package.json

Backend — Express + Prisma

Database Schema (PostgreSQL)

// server/prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  password  String
  name      String
  createdAt DateTime @default(now())
  tasks     Task[]
}

model Task {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  createdAt DateTime @default(now())
  userId    Int
  user      User     @relation(fields: [userId], references: [id])
}

Auth Routes

// server/routes/auth.js
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { PrismaClient } = require("@prisma/client");

const router = express.Router();
const prisma = new PrismaClient();

router.post("/register", async (req, res) => {
  try {
    const { email, password, name } = req.body;

    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) {
      return res.status(400).json({ error: "Email already in use" });
    }

    const hashedPassword = await bcrypt.hash(password, 10);
    const user = await prisma.user.create({
      data: { email, password: hashedPassword, name },
    });

    const token = jwt.sign(
      { userId: user.id },
      process.env.JWT_SECRET,
      { expiresIn: "7d" }
    );

    res.status(201).json({ token, user: { id: user.id, email, name } });
  } catch (error) {
    res.status(500).json({ error: "Registration failed" });
  }
});

router.post("/login", async (req, res) => {
  try {
    const { email, password } = req.body;

    const user = await prisma.user.findUnique({ where: { email } });
    if (!user) {
      return res.status(401).json({ error: "Invalid credentials" });
    }

    const valid = await bcrypt.compare(password, user.password);
    if (!valid) {
      return res.status(401).json({ error: "Invalid credentials" });
    }

    const token = jwt.sign(
      { userId: user.id },
      process.env.JWT_SECRET,
      { expiresIn: "7d" }
    );

    res.json({ token, user: { id: user.id, email: user.email, name: user.name } });
  } catch (error) {
    res.status(500).json({ error: "Login failed" });
  }
});

module.exports = router;

Expected output: POST /api/auth/register with { email, password, name } returns a JWT token and user object. POST /api/auth/login with { email, password } returns the same.

Frontend — Auth Context

// src/context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from "react";
import api from "../api/client";

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const token = localStorage.getItem("token");
    if (token) {
      api.defaults.headers.common["Authorization"] = `Bearer ${token}`;
      // Optionally verify token with backend
      setLoading(false);
    } else {
      setLoading(false);
    }
  }, []);

  function login(token, userData) {
    localStorage.setItem("token", token);
    api.defaults.headers.common["Authorization"] = `Bearer ${token}`;
    setUser(userData);
  }

  function logout() {
    localStorage.removeItem("token");
    delete api.defaults.headers.common["Authorization"];
    setUser(null);
  }

  if (loading) return <div style={{ textAlign: "center", padding: "40px" }}>Loading...</div>;

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within AuthProvider");
  return context;
}

Components Hierarchy

// src/api/client.js
import axios from "axios";

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || "http://localhost:5000/api",
});

export default api;
// src/hooks/useTasks.js
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import api from "../api/client";

export function useTasks(filter = "all") {
  return useQuery({
    queryKey: ["tasks", filter],
    queryFn: async () => {
      const { data } = await api.get(`/tasks?filter=${filter}`);
      return data;
    },
  });
}

export function useCreateTask() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (task) => api.post("/tasks", task),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks"] }),
  });
}

export function useToggleTask() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ id, completed }) =>
      api.patch(`/tasks/${id}`, { completed: !completed }),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks"] }),
  });
}

export function useDeleteTask() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id) => api.delete(`/tasks/${id}`),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks"] }),
  });
}
// src/components/TaskList.jsx
import { useTasks, useToggleTask, useDeleteTask } from "../hooks/useTasks";

function TaskList({ filter }) {
  const { data: tasks, isLoading, isError } = useTasks(filter);
  const toggleMutation = useToggleTask();
  const deleteMutation = useDeleteTask();

  if (isLoading) return <p style={{ textAlign: "center" }}>Loading tasks...</p>;
  if (isError) return <p style={{ color: "red", textAlign: "center" }}>Failed to load tasks</p>;

  return (
    <div style={{ maxWidth: "600px", margin: "0 auto" }}>
      {tasks.length === 0 && (
        <p style={{ textAlign: "center", color: "#666" }}>
          No tasks yet. Create one above!
        </p>
      )}
      {tasks.map(task => (
        <div
          key={task.id}
          style={{
            display: "flex",
            alignItems: "center",
            justifyContent: "space-between",
            padding: "12px 16px",
            borderBottom: "1px solid #eee",
            textDecoration: task.completed ? "line-through" : "none",
            opacity: task.completed ? 0.6 : 1,
          }}
        >
          <label style={{ display: "flex", alignItems: "center", gap: "8px", cursor: "pointer" }}>
            <input
              type="checkbox"
              checked={task.completed}
              onChange={() =>
                toggleMutation.mutate({ id: task.id, completed: task.completed })
              }
            />
            {task.title}
          </label>
          <button
            onClick={() => deleteMutation.mutate(task.id)}
            style={{
              background: "none",
              border: "none",
              color: "#ff4444",
              cursor: "pointer",
              fontSize: "16px",
            }}
          >
            
          </button>
        </div>
      ))}
    </div>
  );
}

Expected output: A task list with checkbox toggles and delete buttons. Loading, empty, and error states are all handled. Mutations update the list automatically via query invalidation.

Architecture Diagram

flowchart TD
    subgraph Frontend [Vite + React]
        A[React App] --> B[AuthContext]
        A --> C[React Query]
        C --> D[Custom Hooks]
        D --> E[Axios API Client]
    end

    subgraph Backend [Node.js + Express]
        F[Express Server] --> G[Auth Routes]
        F --> H[Task Routes]
        G --> I[JWT Middleware]
        H --> I
        I --> J[Prisma ORM]
    end

    subgraph Database [PostgreSQL]
        K[(Users Table)]
        L[(Tasks Table)]
    end

    E -->|HTTP Requests| F
    J --> K
    J --> L

Deployment

Frontend — Vercel

# Install Vercel CLI
npm i -g vercel

# Deploy
vercel --prod

Set environment variables in Vercel dashboard:

  • VITE_API_URL — your production backend URL

Backend — Render

  1. Push your server code to Git (GitHub)
  2. Create a new Web Service on Render
  3. Set build command: npm install && npx prisma generate
  4. Set start command: node index.js
  5. Add environment variables: DATABASE_URL, JWT_SECRET
  6. Deploy

Alternative: Netlify + Supabase

Deploy the frontend on Netlify and use Supabase for the backend (PostgreSQL + Auth). This eliminates the need for a separate Express server.

Security Best Practices

// server/middleware/auth.js
const jwt = require("jsonwebtoken");

function authenticate(req, res, next) {
  const header = req.headers.authorization;
  if (!header || !header.startsWith("Bearer ")) {
    return res.status(401).json({ error: "No token provided" });
  }

  try {
    const token = header.split(" ")[1];
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.userId = decoded.userId;
    next();
  } catch (error) {
    return res.status(401).json({ error: "Invalid or expired token" });
  }
}

Security considerations for production applications:

  • Hash passwords with bcrypt (never store plain text)
  • Set HTTP-only cookies for refresh tokens
  • Rate-limit auth endpoints to prevent brute force
  • Validate and sanitize all user input
  • Use HTTPS in production
  • Store secrets in environment variables, never in code
  • This approach mirrors the security standards used in Doda Browser and Durga Antivirus Pro

Learning Path

flowchart LR
    A[React Basics] --> B[Hooks Guide]
    B --> C[Context API]
    C --> D[Custom Hooks]
    D --> E[Design Patterns]
    E --> F[Error Boundaries]
    F --> G[API Integration]
    G --> H[Final Project]
    H -->|You are here - Course Complete!| H

Common Errors

1. CORS Not Configured

The browser blocks requests from localhost:5173 to localhost:5000 without CORS headers. Add app.use(cors()) in your Express server.

2. Missing JWT_SECRET Environment Variable

If JWT_SECRET is undefined, jwt.sign throws an error silently. Always validate required env vars at server startup.

3. Prisma Client Not Generated

Running npx prisma generate after schema changes is mandatory. Missing this step causes "PrismaClient is not defined" errors.

4. Token Expiration Not Handled

If the JWT expires, API calls return 401 but the frontend still shows the user as logged in. Add an Axios interceptor to redirect to login on 401 responses.

5. Hardcoding API URLs

If http://localhost:5000 is hardcoded, the app does not work in production. Always use environment variables for API URLs.

6. Not Protecting Routes

Without a ProtectedRoute component, users can navigate to /dashboard without authentication. Wrap authenticated pages in a guard that checks for the token.

7. SQL Injection via Raw Queries

Prisma's query builder escapes inputs, but raw SQL queries do not. Never concatenate user input into raw queries. Use parameterized queries or Prisma's safe API.

8. Exposing Environment Variables to the Client

Only variables prefixed with VITE_ are exposed to the browser. Never store secrets like database URLs or JWT secrets in VITE_ prefixed variables.

Practice Questions

Why use Prisma instead of raw SQL queries?

Prisma provides type-safe database access, auto-generated query methods, migration management, and protection against SQL injection. It reduces boilerplate and catches schema errors at build time.

How does JWT authentication work in a full-stack app?

The user sends credentials to the server. The server validates them, creates a signed JWT containing the user ID, and returns it. The frontend stores the token in localStorage and sends it in the Authorization header for subsequent requests.

What is the purpose of the ProtectedRoute component?

ProtectedRoute wraps pages that require authentication. It checks for a valid token and either renders the page or redirects to the login screen. This prevents unauthorized access to private routes.

Why should I deploy the frontend and backend separately?

Separate deployment lets you scale each independently. The frontend (static files) can be served from a CDN, while the backend can scale horizontally behind a load balancer. It also lets you update one without redeploying the other.

How do I handle file uploads in a full-stack React app?

Use Multer (Express middleware) on the backend to handle multipart form data. On the frontend, use FormData with axios. Store files in cloud storage (S3, Cloudinary) rather than on the server filesystem

Challenge

Extend the Task Manager with the following features:

  1. Add a "due date" field to tasks with date picker input
  2. Implement drag-and-drop reordering (use @dnd-kit/core)
  3. Add a real-time collaboration feature using WebSockets so changes from one user appear instantly on another user's screen
  4. Write unit tests with Jest and React Testing Library for the TaskList and TaskForm components

Real-World Task

Deploy the Task Manager to production using Vercel (frontend) and Railway or Render (backend). Set up a custom domain, enable HTTPS, and configure a CI/CD pipeline with GitHub Actions so that pushing to the main branch automatically deploys both services.


Next Steps

Congratulations on completing the full React tutorial series. You have gone from setting up your first component to deploying a full-stack application with authentication, database integration, and production-grade error handling.

Related tutorials:

Start building. The best way to learn React is to build something real. Take your Task Manager, add features, break things, fix them, and ship it.


This tutorial is built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro — production applications trusted by thousands of users worldwide.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro