React Final Project — Build a Full-Stack Application
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
- Push your server code to Git (GitHub)
- Create a new Web Service on Render
- Set build command:
npm install && npx prisma generate - Set start command:
node index.js - Add environment variables:
DATABASE_URL,JWT_SECRET - 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
Challenge
Extend the Task Manager with the following features:
- Add a "due date" field to tasks with date picker input
- Implement drag-and-drop reordering (use
@dnd-kit/core) - Add a real-time collaboration feature using WebSockets so changes from one user appear instantly on another user's screen
- 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:
- React with TypeScript — add type safety to your React apps
- Next.js — build server-rendered and static React sites
- React Native — take your React skills to mobile development
- Advanced CSS — polish your application styling
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