Building Your First MERN App: CRUD, Auth & Zod Validation
Who is this for? If you know a little JavaScript and have heard words like "React", "Node", or "MongoDB" but haven't put them all together yet — this guide is written for you. We'll go slowly, explain
Who is this for? If you know a little JavaScript and have heard words like "React", "Node", or "MongoDB" but haven't put them all together yet — this guide is written for you. We'll go slowly, explain every step, and build something real.
What Are We Building?
A Notes App where users can:
Register & Log in (Authentication)
Create, Read, Update, Delete notes (CRUD)
Validate all inputs properly (Zod)
By the end, you'll have a full-stack app running on your computer.
What is the MERN Stack?
MERN is just a name for four technologies used together:
| Letter | Technology | What it does |
|---|---|---|
| M | MongoDB | Stores your data (like a database) |
| E | Express | Handles your server and routes |
| R | React | Your frontend (what users see) |
| N | Node.js | Runs JavaScript on your computer/server |
Think of it like a restaurant:
MongoDB is the fridge (stores ingredients/data)
Express is the kitchen (processes orders)
Node.js is the chef (runs everything)
React is the menu/dining room (what customers see)
Prerequisites — Install These First
Before we write a single line of code, install:
Node.js → nodejs.org (pick the LTS version)
MongoDB → mongodb.com/try/download/community OR use the free cloud version at mongodb.com/atlas
VS Code → code.visualstudio.com
Postman (optional, for testing) → postman.com
Check Node is installed by opening your terminal and typing:
node --version
# Should print something like: v20.11.0
Project Structure
Here's what our project will look like when we're done:
mern-notes-app/
├── backend/
│ ├── models/
│ │ └── User.js
│ │ └── Note.js
│ ├── routes/
│ │ └── auth.js
│ │ └── notes.js
│ ├── middleware/
│ │ └── authMiddleware.js
│ ├── validators/
│ │ └── schemas.js ← Zod lives here
│ ├── .env
│ └── server.js
└── frontend/
└── src/
├── pages/
│ ├── Login.jsx
│ ├── Register.jsx
│ └── Notes.jsx
├── App.jsx
└── main.jsx
Part 1: Setting Up the Backend
Step 1.1 — Create the project
Open your terminal and run these commands one by one:
mkdir mern-notes-app
cd mern-notes-app
mkdir backend
cd backend
npm init -y
npm init -y creates a package.json file — think of it as the ID card for your project that lists all the tools it uses.
Step 1.2 — Install backend packages
npm install express mongoose dotenv bcryptjs jsonwebtoken zod cors
npm install --save-dev nodemon
What did we just install?
express→ Our server frameworkmongoose→ Talks to MongoDB in a friendly waydotenv→ Lets us hide secret values (like passwords) in a.envfilebcryptjs→ Hashes passwords so we never store them as plain textjsonwebtoken→ Creates tokens so users stay logged inzod→ Validates data (makes sure inputs are correct)cors→ Allows our React app to talk to our servernodemon→ Restarts the server automatically when we save files
Step 1.3 — Update package.json scripts
Open backend/package.json and find the "scripts" section. Replace it with:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Step 1.4 — Create the .env file
In the backend/ folder, create a file named .env (yes, just .env with a dot):
PORT=5000
MONGO_URI=mongodb://localhost:27017/mern-notes
JWT_SECRET=supersecretkey123changemelater
Important: Never share your
.envfile or push it to GitHub. Add.envto your.gitignorefile.
Part 2: Connecting to MongoDB
Step 2.1 — Create server.js
In the backend/ folder, create server.js:
// server.js
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config(); // Load .env variables
const app = express();
// Middleware
app.use(cors()); // Allow frontend to connect
app.use(express.json()); // Parse incoming JSON data
// Routes (we'll add these later)
const authRoutes = require('./routes/auth');
const noteRoutes = require('./routes/notes');
app.use('/api/auth', authRoutes);
app.use('/api/notes', noteRoutes);
// Connect to MongoDB, then start server
mongoose
.connect(process.env.MONGO_URI)
.then(() => {
console.log('✅ Connected to MongoDB');
app.listen(process.env.PORT, () => {
console.log(` Server running on port ${process.env.PORT}`);
});
})
.catch((err) => {
console.error('MongoDB connection failed:', err.message);
});
Part 3: Creating Models
A model tells MongoDB what shape your data should be.
Step 3.1 — User Model
Create backend/models/User.js:
// models/User.js
const mongoose = require('mongoose');
// Define what a "User" looks like in our database
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: true, // This field must exist
trim: true, // Remove extra spaces
},
email: {
type: String,
required: true,
unique: true, // No two users can have the same email
lowercase: true,
},
password: {
type: String,
required: true,
},
},
{ timestamps: true } // Automatically adds createdAt and updatedAt
);
module.exports = mongoose.model('User', userSchema);
Step 3.2 — Note Model
Create backend/models/Note.js:
// models/Note.js
const mongoose = require('mongoose');
const noteSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
},
content: {
type: String,
required: true,
},
// Each note belongs to a specific user
user: {
type: mongoose.Schema.Types.ObjectId, // A reference to a User document
ref: 'User',
required: true,
},
},
{ timestamps: true }
);
module.exports = mongoose.model('Note', noteSchema);
💡 What is ObjectId? MongoDB gives every document a unique ID. When a note has
user: ObjectId("abc123"), it means "this note belongs to the user whose ID is abc123". This is how we connect documents together.
Part 4: Input Validation with Zod
This is where Zod comes in. Zod lets us define rules for what data is acceptable, and it gives friendly error messages when the rules are broken.
Step 4.1 — Create Zod Schemas
Create backend/validators/schemas.js:
// validators/schemas.js
const { z } = require('zod');
// Rules for registering a new user
const registerSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name cannot exceed 50 characters'),
email: z
.string()
.email('Please enter a valid email address'),
password: z
.string()
.min(6, 'Password must be at least 6 characters'),
});
// Rules for logging in
const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z.string().min(1, 'Password is required'),
});
// Rules for creating or updating a note
const noteSchema = z.object({
title: z
.string()
.min(1, 'Title cannot be empty')
.max(100, 'Title is too long'),
content: z
.string()
.min(1, 'Content cannot be empty'),
});
module.exports = { registerSchema, loginSchema, noteSchema };
Step 4.2 — Create a Validation Middleware
Middleware is a function that runs between receiving a request and sending a response. We'll use it to validate data automatically.
Create backend/middleware/validate.js:
// middleware/validate.js
// This function takes a Zod schema and returns a middleware function
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
// Zod gives us a list of errors — let's format them nicely
const errors = result.error.errors.map((err) => ({
field: err.path[0], // Which field has the error
message: err.message, // What the error message is
}));
return res.status(400).json({
success: false,
errors,
});
}
// If validation passed, save the cleaned data and continue
req.validatedData = result.data;
next();
};
module.exports = validate;
💡
safeParsetries to validate the data but doesn't throw an error if it fails — it returns an object withsuccess: true/falseinstead. This lets us handle errors gracefully.
Part 5: Authentication Routes
Step 5.1 — Create Auth Middleware
Before users can access their notes, we need to verify they're logged in. Create backend/middleware/authMiddleware.js:
// middleware/authMiddleware.js
const jwt = require('jsonwebtoken');
const protect = (req, res, next) => {
// The token is sent in the request header like: "Bearer eyJhb..."
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Not authorized, no token' });
}
const token = authHeader.split(' ')[1]; // Get the part after "Bearer "
try {
// Verify the token using our secret key
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.id; // Attach the user's ID to the request
next(); // Continue to the next function
} catch (error) {
return res.status(401).json({ message: 'Token is invalid or expired' });
}
};
module.exports = protect;
Step 5.2 — Register & Login Routes
Create backend/routes/auth.js:
// routes/auth.js
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const validate = require('../middleware/validate');
const { registerSchema, loginSchema } = require('../validators/schemas');
// Helper function to create a JWT token
const createToken = (userId) => {
return jwt.sign(
{ id: userId }, // What we store inside the token
process.env.JWT_SECRET, // Secret key to sign it
{ expiresIn: '7d' } // Token expires in 7 days
);
};
// POST /api/auth/register
router.post('/register', validate(registerSchema), async (req, res) => {
try {
const { name, email, password } = req.validatedData;
// Check if a user with this email already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'Email already in use' });
}
// Hash the password — NEVER store plain text passwords!
// The "12" is the "salt rounds" — higher = more secure but slower
const hashedPassword = await bcrypt.hash(password, 12);
// Create and save the new user
const user = await User.create({
name,
email,
password: hashedPassword,
});
// Send back a token so they're immediately logged in
const token = createToken(user._id);
res.status(201).json({
success: true,
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// POST /api/auth/login
router.post('/login', validate(loginSchema), async (req, res) => {
try {
const { email, password } = req.validatedData;
// Find the user by email
const user = await User.findOne({ email });
if (!user) {
return res.status(400).json({ message: 'Invalid email or password' });
}
// Compare entered password with the stored hashed password
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(400).json({ message: 'Invalid email or password' });
}
const token = createToken(user._id);
res.json({
success: true,
token,
user: { id: user._id, name: user.name, email: user.email },
});
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router;
Why do we give the same error for wrong email AND wrong password? Security! If we said "email not found", a hacker could use that to figure out which emails are registered.
Part 6: CRUD Routes for Notes
Create backend/routes/notes.js:
// routes/notes.js
const express = require('express');
const router = express.Router();
const Note = require('../models/Note');
const protect = require('../middleware/authMiddleware');
const validate = require('../middleware/validate');
const { noteSchema } = require('../validators/schemas');
// All note routes are protected — user must be logged in
router.use(protect);
// ─────────────────────────────────────────
// CREATE a note → POST /api/notes
// ─────────────────────────────────────────
router.post('/', validate(noteSchema), async (req, res) => {
try {
const { title, content } = req.validatedData;
const note = await Note.create({
title,
content,
user: req.userId, // We know who's logged in from our auth middleware
});
res.status(201).json({ success: true, note });
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
// ─────────────────────────────────────────
// READ all notes → GET /api/notes
// ─────────────────────────────────────────
router.get('/', async (req, res) => {
try {
// Only get notes that belong to the logged-in user
const notes = await Note.find({ user: req.userId }).sort({ createdAt: -1 });
res.json({ success: true, count: notes.length, notes });
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
// ─────────────────────────────────────────
// READ one note → GET /api/notes/:id
// ─────────────────────────────────────────
router.get('/:id', async (req, res) => {
try {
const note = await Note.findById(req.params.id);
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
// Make sure the note belongs to the logged-in user
if (note.user.toString() !== req.userId) {
return res.status(403).json({ message: 'Not authorized' });
}
res.json({ success: true, note });
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
// ─────────────────────────────────────────
// UPDATE a note → PUT /api/notes/:id
// ─────────────────────────────────────────
router.put('/:id', validate(noteSchema), async (req, res) => {
try {
const note = await Note.findById(req.params.id);
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
if (note.user.toString() !== req.userId) {
return res.status(403).json({ message: 'Not authorized' });
}
const { title, content } = req.validatedData;
note.title = title;
note.content = content;
await note.save();
res.json({ success: true, note });
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
// ─────────────────────────────────────────
// DELETE a note → DELETE /api/notes/:id
// ─────────────────────────────────────────
router.delete('/:id', async (req, res) => {
try {
const note = await Note.findById(req.params.id);
if (!note) {
return res.status(404).json({ message: 'Note not found' });
}
if (note.user.toString() !== req.userId) {
return res.status(403).json({ message: 'Not authorized' });
}
await note.deleteOne();
res.json({ success: true, message: 'Note deleted' });
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
module.exports = router;
Test Your Backend
Start the server:
cd backend
npm run dev
You should see:
Connected to MongoDB
Server running on port 5000
Open Postman (or any API tool) and test:
POST http://localhost:5000/api/auth/registerwith body{ "name": "Alice", "email": "alice@test.com", "password": "secret123" }Try sending an invalid email — Zod will catch it! 🎉
Part 7: The React Frontend
Step 7.1 — Create React App
Open a new terminal, navigate back to the root folder:
cd .. # Go back to mern-notes-app/
npm create vite@latest frontend -- --template react
cd frontend
npm install
npm install axios react-router-dom
We install:
axios→ Makes HTTP requests to our backend (easier thanfetch)react-router-dom→ Handles navigation between pages
Step 7.2 — Set Up Axios
Create frontend/src/api.js:
// src/api.js
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:5000/api',
});
// Before every request, attach the token if we have one
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default api;
Step 7.3 — Register Page
Create frontend/src/pages/Register.jsx:
// pages/Register.jsx
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import api from '../api';
export default function Register() {
const navigate = useNavigate();
const [form, setForm] = useState({ name: '', email: '', password: '' });
const [errors, setErrors] = useState([]);
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault(); // Prevent page refresh
setErrors([]);
setLoading(true);
try {
const { data } = await api.post('/auth/register', form);
localStorage.setItem('token', data.token); // Save token
navigate('/notes'); // Go to notes page
} catch (err) {
// Show validation errors from Zod or server errors
if (err.response?.data?.errors) {
setErrors(err.response.data.errors);
} else {
setErrors([{ message: err.response?.data?.message || 'Something went wrong' }]);
}
} finally {
setLoading(false);
}
};
return (
<div style={{ maxWidth: '400px', margin: '50px auto', padding: '20px' }}>
<h2>Create an Account</h2>
{/* Show errors if any */}
{errors.map((err, i) => (
<p key={i} style={{ color: 'red' }}>
{err.field && <strong>{err.field}: </strong>}
{err.message}
</p>
))}
<form onSubmit={handleSubmit}>
<div>
<label>Name</label>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
required
/>
</div>
<div>
<label>Email</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
/>
</div>
<div>
<label>Password</label>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Creating account...' : 'Register'}
</button>
</form>
<p>Already have an account? <Link to="/login">Login</Link></p>
</div>
);
}
Step 7.4 — Login Page
Create frontend/src/pages/Login.jsx:
// pages/Login.jsx
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import api from '../api';
export default function Login() {
const navigate = useNavigate();
const [form, setForm] = useState({ email: '', password: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { data } = await api.post('/auth/login', form);
localStorage.setItem('token', data.token);
navigate('/notes');
} catch (err) {
setError(err.response?.data?.message || 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div style={{ maxWidth: '400px', margin: '50px auto', padding: '20px' }}>
<h2>Welcome Back</h2>
{error && <p style={{ color: 'red' }}>{error}</p>}
<form onSubmit={handleSubmit}>
<div>
<label>Email</label>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
required
/>
</div>
<div>
<label>Password</label>
<input
type="password"
name="password"
value={form.password}
onChange={handleChange}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p>Don't have an account? <Link to="/register">Register</Link></p>
</div>
);
}
Step 7.5 — Notes Page (Full CRUD)
Create frontend/src/pages/Notes.jsx:
// pages/Notes.jsx
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '../api';
export default function Notes() {
const navigate = useNavigate();
const [notes, setNotes] = useState([]);
const [form, setForm] = useState({ title: '', content: '' });
const [editingId, setEditingId] = useState(null); // Which note we're editing
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState([]);
// Fetch notes when the page loads
useEffect(() => {
fetchNotes();
}, []);
const fetchNotes = async () => {
try {
const { data } = await api.get('/notes');
setNotes(data.notes);
} catch (err) {
// If token is invalid, send to login
if (err.response?.status === 401) navigate('/login');
}
};
const handleChange = (e) => {
setForm({ ...form, [e.target.name]: e.target.value });
};
const handleSubmit = async (e) => {
e.preventDefault();
setErrors([]);
setLoading(true);
try {
if (editingId) {
// UPDATE existing note
await api.put(`/notes/${editingId}`, form);
setEditingId(null);
} else {
// CREATE new note
await api.post('/notes', form);
}
setForm({ title: '', content: '' });
fetchNotes(); // Refresh the list
} catch (err) {
if (err.response?.data?.errors) {
setErrors(err.response.data.errors);
}
} finally {
setLoading(false);
}
};
const handleEdit = (note) => {
setEditingId(note._id);
setForm({ title: note.title, content: note.content });
};
const handleDelete = async (id) => {
if (!window.confirm('Delete this note?')) return;
try {
await api.delete(`/notes/${id}`);
fetchNotes();
} catch (err) {
alert('Failed to delete note');
}
};
const handleLogout = () => {
localStorage.removeItem('token');
navigate('/login');
};
return (
<div style={{ maxWidth: '600px', margin: '30px auto', padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h2>My Notes</h2>
<button onClick={handleLogout}>Logout</button>
</div>
{/* Create / Edit Form */}
<form onSubmit={handleSubmit} style={{ marginBottom: '30px' }}>
<h3>{editingId ? 'Edit Note' : 'Add a Note'}</h3>
{errors.map((err, i) => (
<p key={i} style={{ color: 'red' }}>
{err.field && <strong>{err.field}: </strong>}
{err.message}
</p>
))}
<div>
<input
type="text"
name="title"
placeholder="Title"
value={form.title}
onChange={handleChange}
style={{ width: '100%', marginBottom: '8px', padding: '8px' }}
/>
</div>
<div>
<textarea
name="content"
placeholder="Write your note here..."
value={form.content}
onChange={handleChange}
rows={4}
style={{ width: '100%', marginBottom: '8px', padding: '8px' }}
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Saving...' : editingId ? 'Update Note' : 'Add Note'}
</button>
{editingId && (
<button
type="button"
onClick={() => { setEditingId(null); setForm({ title: '', content: '' }); }}
style={{ marginLeft: '10px' }}
>
Cancel
</button>
)}
</form>
{/* Notes List */}
{notes.length === 0 ? (
<p>No notes yet. Add your first one above! 👆</p>
) : (
notes.map((note) => (
<div
key={note._id}
style={{ border: '1px solid #ddd', padding: '15px', marginBottom: '10px', borderRadius: '8px' }}
>
<h3 style={{ margin: '0 0 8px 0' }}>{note.title}</h3>
<p style={{ margin: '0 0 10px 0' }}>{note.content}</p>
<small style={{ color: '#888' }}>
{new Date(note.createdAt).toLocaleDateString()}
</small>
<div style={{ marginTop: '10px' }}>
<button onClick={() => handleEdit(note)}>✏️ Edit</button>
<button
onClick={() => handleDelete(note._id)}
style={{ marginLeft: '8px', color: 'red' }}
>
🗑️ Delete
</button>
</div>
</div>
))
)}
</div>
);
}
Step 7.6 — Wire Up Routing
Replace the contents of frontend/src/App.jsx:
// App.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Register from './pages/Register';
import Login from './pages/Login';
import Notes from './pages/Notes';
// Simple check — is the user logged in?
const isLoggedIn = () => !!localStorage.getItem('token');
// Protected route — redirects to login if not logged in
const PrivateRoute = ({ children }) => {
return isLoggedIn() ? children : <Navigate to="/login" />;
};
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/notes" />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route
path="/notes"
element={
<PrivateRoute>
<Notes />
</PrivateRoute>
}
/>
</Routes>
</BrowserRouter>
);
}
▶️ Start the Frontend
cd frontend
npm run dev
Open http://localhost:5173 in your browser. You now have a working app!
How It All Flows Together
Here's what happens when a user creates a note:
User types a note → clicks "Add Note"
↓
React sends POST /api/notes with { title, content }
↓
Express receives the request
↓
authMiddleware checks the JWT token → confirms who the user is
↓
validate(noteSchema) → Zod checks title & content are valid
↓
Note.create() → saves to MongoDB
↓
Server sends back the new note
↓
React updates the list on screen
Common Errors & Fixes
| Error | Cause | Fix |
|---|---|---|
Cannot connect to MongoDB |
MongoDB isn't running | Start MongoDB service or check Atlas connection string |
CORS error in browser |
Frontend blocked by backend | Make sure app.use(cors()) is in server.js |
401 Unauthorized |
Token missing or expired | Log out and log back in |
Cannot read properties of undefined |
Data not loaded yet | Add loading states |
Zod validation error |
Input doesn't match schema | Check your input against the Zod rules |
Key Concepts Recap
| Concept | What it is | Where we used it |
|---|---|---|
| Model | Blueprint for database documents | User.js, Note.js |
| Route | URL endpoint that handles a request | routes/auth.js, routes/notes.js |
| Middleware | Function that runs between request and response | authMiddleware.js, validate.js |
| JWT | Token proving you're logged in | Created on login, checked on every request |
| Hashing | One-way encryption for passwords | bcrypt.hash() on register, bcrypt.compare() on login |
| Zod | Input validation library | validators/schemas.js |
| CRUD | Create, Read, Update, Delete | All 5 note routes |
What to Explore Next
Now that you have the basics down, here are natural next steps:
Add styling — Use Tailwind CSS or a component library like shadcn/ui
Add refresh tokens — So users don't get logged out after 7 days unexpectedly
Deploy the frontend — Try Vercel (free and super easy)
Add search — Filter notes by title using MongoDB queries
Learn TypeScript — Add type safety to your JavaScript
Resources
Zod docs → zod.dev
Mongoose docs → mongoosejs.com
Express docs → expressjs.com
JWT explained → jwt.io/introduction
React Router → reactrouter.com
Happy coding! Remember — every expert was once a beginner. The fact that you're building full-stack apps already puts you ahead. Keep going.