π οΈ Weekend Project: JavaScript Web Frameworks
This is the module where the pieces click together. Over one weekend you'll build and ship a complete MERN task manager β React on the front, Express and MongoDB on the back, and real JWT authentication in between. We'll run it like a real sprint: a milestone map, a time budget, a build checklist, and a rubric for what "done well" actually looks like.
π― Learning Objectives
By the end of this project, you will be able to:
- Plan a full-stack feature build using milestones and a realistic time budget instead of coding blindly
- Scaffold a MERN app with Vite and Express, wired together with a dev proxy
- Implement JWT authentication with access + refresh tokens, hashed passwords, and protected routes
- Build an ownership-scoped CRUD API and a React 18/19 frontend using Context, hooks, and an Axios interceptor
- Self-assess your build against a "what good looks like" rubric and a security checklist
Estimated Time: One weekend (8β12 focused hours) β’ Difficulty: Intermediate
Hands-on: This entire lesson is the build. Follow the milestones in order, tick the checklist, and ship it.
In This Lesson
The Weekend Brief
You've spent this module learning the JavaScript web-framework stack in pieces: a React frontend, an Express backend, Axios for API calls, and the authentication flow that ties a user to their data. A weekend project is where those pieces stop being separate lessons and become one running application you can show someone.
Your deliverable is a Task Manager. A visitor can register, log in, and then create, edit, complete, filter, and delete their own tasks β and only their own. It's small enough to finish in a weekend and complete enough to prove you can run a feature end to end.
π What "MERN" means
MongoDB (the database) Β· Express (the backend framework) Β· React (the frontend library) Β· Node.js (the JavaScript runtime the backend runs on). One language β JavaScript β from the browser all the way to the database query.
Here's the feature list you're committing to. Treat anything below the line as a stretch goal β ship the core first.
β Core scope (must ship)
- Register and log in with email + password
- JWT access token + refresh-token rotation
- Create, read, update, delete tasks (title, description, status, priority, due date)
- Filter by status/priority and sort the task list
- Routes that only the logged-in owner can reach
β οΈ Stretch goals (only if core is done and working)
- Profile editing, password reset, task tags or categories, deployment to a host
How to Attack a Weekend Build
The single biggest mistake in a weekend project is opening the editor and typing. You lose Saturday to a database connection bug and never reach the UI. A tiny bit of planning protects your weekend. We'll borrow a 90-year-old framework that still beats most modern advice: mathematician George PΓ³lya's four steps from How to Solve It (1945).
| PΓ³lya's step | What it means for this build |
|---|---|
| 1. Understand the problem | Nail the scope (above) and the data models before writing code. Know what "done" is. |
| 2. Devise a plan | Break the app into milestones you can finish and test one at a time. |
| 3. Carry out the plan | Build one milestone, verify it works, commit, then move on. Never build two at once. |
| 4. Look back | Test against the checklist and rubric, note what you'd improve, refactor the ugliest bit. |
π‘ Vertical slices beat horizontal layers. It's tempting to build all the models, then all the routes, then all the UI. Resist it. Build one thin slice β "a logged-in user can create a task and see it" β all the way through the stack first. A working slice is worth ten half-finished layers.
Commit after every green milestone. A passing milestone is a save point. If Sunday's refactor goes sideways, git reset costs you minutes instead of the weekend.
The Milestone Map
Six milestones, roughly in dependency order. The time budget is a guide, not a rule β the point is to give each phase a ceiling so no single one eats the weekend.
Milestone 0 β Scaffold & Wire Up
Goal: a client and a server that both start, and a browser request that reaches Express. We use Vite for the React app (Create React App is deprecated) and a small Express server beside it.
Create a monorepo-style layout with a client and a server folder:
# Project root
mkdir task-manager && cd task-manager
git init
printf "node_modules\n.env\ndist\n.DS_Store\n" > .gitignore
# Frontend: Vite + React
npm create vite@latest client -- --template react
cd client && npm install
npm install axios react-router-dom
cd ..
# Backend: Express + tooling
mkdir server && cd server
npm init -y
npm install express mongoose bcryptjs jsonwebtoken cors dotenv express-validator
npm install --save-dev nodemon
cd ..
Give the server an entry point that connects to MongoDB and mounts routes you'll fill in next. Note the modern Mongoose call β useNewUrlParser / useUnifiedTopology are no longer needed (Mongoose 6+ ignores them):
// server/server.js
import express from 'express';
import cors from 'cors';
import mongoose from 'mongoose';
import 'dotenv/config';
await mongoose.connect(process.env.MONGO_URI);
console.log('MongoDB connected');
const app = express();
app.use(cors());
app.use(express.json());
app.get('/api/health', (req, res) => res.json({ ok: true }));
// app.use('/api/auth', authRoutes); // β Milestone 2
// app.use('/api/tasks', taskRoutes); // β Milestone 3
// Central error handler β every controller can just `throw`
app.use((err, req, res, next) => {
console.error(err);
res.status(err.status || 500).json({ message: err.message || 'Server error' });
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`API on http://localhost:${PORT}`));
π‘ Use "type": "module"
Add "type": "module" to server/package.json so you can use import syntax (matching your React code) instead of require. Add scripts too: "dev": "nodemon server.js".
Point Vite's dev server at Express so /api/* calls proxy across during development β no CORS headaches, and the frontend code just calls /api/tasks:
// client/vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: { '/api': 'http://localhost:5000' }
}
});
Create server/.env (and never commit it):
PORT=5000
MONGO_URI=mongodb://localhost:27017/task-manager
JWT_SECRET=replace_with_a_long_random_string
JWT_EXPIRE=15m
REFRESH_TOKEN_SECRET=replace_with_a_different_long_random_string
REFRESH_TOKEN_EXPIRE=7d
β Milestone 0 is done whenβ¦
Both npm run dev (in server) and npm run dev (in client) start cleanly, and visiting http://localhost:5000/api/health returns { "ok": true }. Commit it.
Milestone 1 β Data Models
Goal: Mongoose schemas that describe a user and a task, with the password-hashing logic baked into the model so no controller can forget it.
Two collections, one relationship: a user owns many tasks.
The user model hashes the password with a pre('save') hook and exposes a matchPassword helper. select: false keeps the hash out of every normal query result:
// server/models/User.js
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';
const userSchema = new mongoose.Schema({
name: { type: String, required: [true, 'Name is required'], trim: true, maxlength: 50 },
email: {
type: String, required: true, unique: true, lowercase: true, trim: true,
match: [/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Please provide a valid email']
},
password: { type: String, required: true, minlength: 6, select: false }
}, { timestamps: true }); // adds createdAt / updatedAt automatically
// Hash only when the password actually changed
userSchema.pre('save', async function () {
if (!this.isModified('password')) return;
this.password = await bcrypt.hash(this.password, 10);
});
userSchema.methods.matchPassword = function (candidate) {
return bcrypt.compare(candidate, this.password);
};
export default mongoose.model('User', userSchema);
The task model enforces the allowed values and, crucially, indexes by owner so "all of my tasks" is a fast query:
// server/models/Task.js
import mongoose from 'mongoose';
const taskSchema = new mongoose.Schema({
title: { type: String, required: [true, 'Title is required'], trim: true, maxlength: 100 },
description: { type: String, trim: true, maxlength: 500 },
status: { type: String, enum: ['pending', 'in-progress', 'completed'], default: 'pending' },
priority: { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
dueDate: { type: Date },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true, index: true }
}, { timestamps: true });
export default mongoose.model('Task', taskSchema);
π‘ Let the schema do the validating
Every enum, maxlength, and required here is a rule the database enforces no matter which route touches it. Pushing validation down into the model means a bug in one controller can't corrupt your data.
Done when: you can open a Node REPL or a scratch route, create a User, and confirm the stored password is a bcrypt hash, not plain text.
Milestone 2 β Auth API
Goal: register, log in, refresh, and a middleware that guards private routes. This is the milestone people underestimate β budget two hours and test each endpoint before moving on.
Access tokens are short-lived (minutes); the refresh token is long-lived (days) and is used to mint new access tokens without asking the user to log in again.
A helper mints both tokens, and the controllers stay small because validation and errors are handled elsewhere:
// server/controllers/authController.js
import jwt from 'jsonwebtoken';
import User from '../models/User.js';
const signTokens = (userId) => ({
accessToken: jwt.sign({ id: userId }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRE }),
refreshToken: jwt.sign({ id: userId }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: process.env.REFRESH_TOKEN_EXPIRE })
});
const publicUser = (u) => ({ id: u._id, name: u.name, email: u.email });
export const register = async (req, res) => {
const { name, email, password } = req.body;
if (await User.findOne({ email }))
return res.status(409).json({ message: 'Email already registered' });
const user = await User.create({ name, email, password });
res.status(201).json({ ...signTokens(user._id), user: publicUser(user) });
};
export const login = async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.matchPassword(password)))
return res.status(401).json({ message: 'Invalid credentials' });
res.json({ ...signTokens(user._id), user: publicUser(user) });
};
export const refresh = async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(400).json({ message: 'No refresh token' });
try {
const { id } = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
res.json(signTokens(id));
} catch {
res.status(401).json({ message: 'Invalid refresh token' });
}
};
The protect middleware reads the Bearer token, verifies it, and attaches the user id to the request. Every private route sits behind it:
// server/middleware/protect.js
import jwt from 'jsonwebtoken';
export const protect = (req, res, next) => {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ message: 'Not authorized' });
try {
const { id } = jwt.verify(token, process.env.JWT_SECRET);
req.userId = id;
next();
} catch {
res.status(401).json({ message: 'Token invalid or expired' });
}
};
Wire the routes, running validation before each controller:
// server/routes/auth.js
import { Router } from 'express';
import { body } from 'express-validator';
import { register, login, refresh } from '../controllers/authController.js';
import { runValidation } from '../middleware/runValidation.js';
const router = Router();
router.post('/register',
body('name').notEmpty(),
body('email').isEmail(),
body('password').isLength({ min: 6 }),
runValidation, register);
router.post('/login',
body('email').isEmail(),
body('password').notEmpty(),
runValidation, login);
router.post('/refresh', refresh);
export default router;
β οΈ Two traps that eat an hour each
- Different secrets. Access and refresh tokens must be signed with different secrets. If they share one, a leaked access token can forge refreshes.
- The classic typo. In a route file,
import { Router } from 'express'β not from'mongoose'. The original course code had exactly this bug; it fails with a cryptic "Router is not a function".
Done when: using curl or a REST client, you can register, log in, hit a temporary protected route with the returned token (200), and hit it without one (401).
Milestone 3 β Task CRUD API
Goal: five endpoints, every one scoped to the logged-in user. The security rule of this milestone: a query must never trust an id from the URL alone. Always pin it to req.userId.
Because protect already set req.userId, ownership scoping is just part of the query filter β there's no separate "does this belong to them?" branch to forget:
// server/controllers/taskController.js
import Task from '../models/Task.js';
// GET /api/tasks?status=pending&priority=high&sort=-createdAt
export const getTasks = async (req, res) => {
const { status, priority, sort = '-createdAt' } = req.query;
const filter = { user: req.userId };
if (status) filter.status = status;
if (priority) filter.priority = priority;
const tasks = await Task.find(filter).sort(sort);
res.json({ count: tasks.length, data: tasks });
};
// POST /api/tasks
export const createTask = async (req, res) => {
const task = await Task.create({ ...req.body, user: req.userId });
res.status(201).json({ data: task });
};
// PUT /api/tasks/:id β the filter itself enforces ownership
export const updateTask = async (req, res) => {
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, user: req.userId },
req.body,
{ new: true, runValidators: true }
);
if (!task) return res.status(404).json({ message: 'Task not found' });
res.json({ data: task });
};
// DELETE /api/tasks/:id
export const deleteTask = async (req, res) => {
const task = await Task.findOneAndDelete({ _id: req.params.id, user: req.userId });
if (!task) return res.status(404).json({ message: 'Task not found' });
res.json({ message: 'Task deleted' });
};
π Why findOneAndDelete, not task.remove()?
The document-instance method .remove() was removed in Mongoose 7. Use findOneAndDelete / deleteOne. Filtering by { _id, user } in one call also closes the security gap where you fetch first, forget the ownership check, and delete someone else's task.
The routes are a two-liner thanks to Express's route() chaining, all behind protect:
// server/routes/tasks.js
import { Router } from 'express';
import { protect } from '../middleware/protect.js';
import { getTasks, createTask, updateTask, deleteTask } from '../controllers/taskController.js';
const router = Router();
router.use(protect); // guard every task route
router.route('/').get(getTasks).post(createTask);
router.route('/:id').put(updateTask).delete(deleteTask);
export default router;
Uncomment the two app.use(...) lines in server.js. Done when: logged in as user A you can create and list tasks, and a task's _id from user A returns 404 (not 200) when user B tries to update it.
Milestone 4 β React Frontend
Goal: a React 18/19 app that logs a user in, keeps them in an auth context, auto-refreshes expired tokens, and shows their tasks. This is the biggest milestone β build the vertical slice (login β see tasks) before adding filters and edit forms.
The Axios instance that refreshes itself
One interceptor attaches the token to every request; another catches a 401, silently gets a new access token, and replays the failed request. Your components never think about tokens:
// client/src/api.js
import axios from 'axios';
const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
original.headers.Authorization = `Bearer ${data.accessToken}`;
return api(original); // replay the original request
} catch {
localStorage.clear();
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;
Auth context with hooks
A context holds the user and exposes login, register, and logout. A custom useAuth hook keeps components tidy:
// client/src/context/AuthContext.jsx
import { createContext, useContext, useState, useEffect } from 'react';
import api from '../api';
const AuthContext = createContext(null);
export const useAuth = () => useContext(AuthContext);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!localStorage.getItem('accessToken')) return setLoading(false);
api.get('/auth/me')
.then((res) => setUser(res.data.user))
.catch(() => localStorage.clear())
.finally(() => setLoading(false));
}, []);
const persist = ({ accessToken, refreshToken, user }) => {
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
};
const login = async (creds) => persist((await api.post('/auth/login', creds)).data);
const register = async (data) => persist((await api.post('/auth/register', data)).data);
const logout = () => { localStorage.clear(); setUser(null); };
return (
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
π‘ Add a GET /api/auth/me route
The context calls it on load to restore the session from a stored token. It's a two-liner behind protect: look up req.userId and return the public user. Reusing the same protect middleware here is the payoff for building it well in Milestone 2.
Protecting routes
A tiny wrapper redirects anyone without a session away from private pages:
// client/src/components/ProtectedRoute.jsx
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) return <p>Loadingβ¦</p>;
return user ? children : <Navigate to="/login" replace />;
}
// client/src/App.jsx
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/" element={
<ProtectedRoute><Dashboard /></ProtectedRoute>
} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
The dashboard: fetch, create, complete
A focused component that loads tasks on mount and lets the user add and toggle them. Filters and edit forms come after this works:
// client/src/pages/Dashboard.jsx
import { useState, useEffect } from 'react';
import api from '../api';
import { useAuth } from '../context/AuthContext';
export default function Dashboard() {
const { user, logout } = useAuth();
const [tasks, setTasks] = useState([]);
const [title, setTitle] = useState('');
const loadTasks = async () => {
const { data } = await api.get('/tasks');
setTasks(data.data);
};
useEffect(() => { loadTasks(); }, []);
const addTask = async (e) => {
e.preventDefault();
if (!title.trim()) return;
await api.post('/tasks', { title });
setTitle('');
loadTasks();
};
const toggle = async (task) => {
const status = task.status === 'completed' ? 'pending' : 'completed';
await api.put(`/tasks/${task._id}`, { status });
loadTasks();
};
return (
<main>
<header>
<h1>Hi, {user?.name}</h1>
<button onClick={logout}>Log out</button>
</header>
<form onSubmit={addTask}>
<input value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="New taskβ¦" />
<button type="submit">Add</button>
</form>
<ul>
{tasks.map((task) => (
<li key={task._id}>
<input type="checkbox"
checked={task.status === 'completed'}
onChange={() => toggle(task)} />
{task.title} <small>({task.priority})</small>
</li>
))}
</ul>
</main>
);
}
Done when: you can register in the browser, land on the dashboard, add a task, tick it complete, refresh the page, and everything persists.
Milestone 5 β Polish & Ship
Goal: turn a working prototype into something you'd hand to a stranger. Do these in priority order and stop when you run out of weekend β a shipped 80% beats a perfect 100% that never leaves your laptop.
- Loading & empty states. Show a spinner while tasks load and a friendly "No tasks yet β add your first one" when the list is empty.
- Error feedback. Surface failed logins and failed saves to the user (a toast or an inline message), not just
console.log. - Filter & sort UI. Wire dropdowns to the query params your API already supports (
?status=β¦&priority=β¦&sort=β¦). - Form validation. Disable the submit button while a request is in flight; trim and require the title.
- Build & deploy (stretch).
npm run buildinclient, serve the staticdistfrom Express or a static host, and put the API behind a real MongoDB (e.g. Atlas). Move every secret into the host's env vars.
β οΈ Before you deploy: the security pass
- No secrets in git β
.envis git-ignored and secrets live in the host's config. - Passwords are hashed (bcrypt), never stored or logged in plain text.
- Every task query is scoped to
req.userIdβ verified by the "user B gets 404" test. - Access tokens are short-lived; refresh tokens use a separate secret.
- Server-side validation runs even though the client validates too β never trust the client.
Build Checklist
Tick these off as you go. If you can honestly check every box, you've shipped the core project.
β Definition of done
- β M0 β client and server start;
/api/healthreturns JSON - β M1 β User and Task models exist; stored passwords are bcrypt hashes
- β M2 β register / login / refresh work;
protectreturns 401 without a token - β M3 β task CRUD works and is scoped to the owner (user B gets 404 on user A's task)
- β M4 β register in the browser, add and complete a task, and it survives a page refresh
- β M5 β loading/empty/error states, filter & sort, and the security pass all pass
- β Git β a clean commit after every green milestone
What Good Looks Like
Working isn't the same as good. Here's how a reviewer (or a future you) tells a rushed build from a solid one across the same feature.
| Area | Just working | Good |
|---|---|---|
| Ownership | Fetches a task, then checks task.user === userId in a separate if |
Ownership is in the query (findOne({ _id, user })) so it can't be skipped |
| Errors | Each controller has its own try/catch that logs and returns 500 |
One central error handler; controllers stay focused on the happy path |
| Tokens | A long-lived token in localStorage, no refresh | Short access token + refresh rotation handled invisibly by an interceptor |
| State | Auth flags copied into many components with prop-drilling | One auth context and a useAuth hook; components read what they need |
| UX | Blank screen while loading; failures vanish into the console | Loading, empty, and error states are all visible to the user |
| Validation | Only the React form checks input | Schema + server validation too β the API is safe on its own |
π‘ The look-back habit. When the build works, spend twenty minutes on PΓ³lya's step 4: write down the one thing that fought you hardest and the one bit of code you're least proud of. That short note is where your next project gets easier.
Quiz
π― Check Your Understanding
Question 1: Why should a task query filter by { _id: req.params.id, user: req.userId } instead of fetching by id and then checking ownership afterward?
Question 2: What is the main reason to use a short-lived access token together with a longer-lived refresh token?
Question 3: In a weekend build, why commit after each green milestone and build one vertical slice before adding features?
Summary & What's Next
π Key Takeaways
- A weekend build succeeds or fails on planning: scope it, break it into milestones, and build one testable slice at a time.
- The MERN slice β React β Express β MongoDB β is one JavaScript language across three boxes, tied together by JWT auth.
- Scope every query to the owner and let the schema and a central error handler do the heavy lifting so controllers stay small.
- An Axios interceptor plus an auth context keep token handling out of your components entirely.
- "Working" and "good" differ β use the rubric and security pass to close the gap before you call it done.
π Further Reading
- Vite β Getting Started
- react.dev β Learn React (hooks & context)
- Mongoose β Schemas & Models
- JSON Web Tokens β Introduction
- OWASP Top 10 β Web Application Security Risks
π What's Next?
You've now shipped a full JavaScript-stack application. Next module we cross the language line: we'll rebuild the same kind of backend in Python with Flask, so you can see how the exact concepts β routing, models, auth, JSON APIs β carry over to a completely different ecosystem.
π You shipped it!
A real, authenticated, database-backed app β planned, built, and reviewed like a pro. That's a portfolio piece.