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

Ayush Basak

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:

  1. Node.jsnodejs.org (pick the LTS version)

  2. MongoDBmongodb.com/try/download/community OR use the free cloud version at mongodb.com/atlas

  3. VS Codecode.visualstudio.com

  4. 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 framework

  • mongoose → Talks to MongoDB in a friendly way

  • dotenv → Lets us hide secret values (like passwords) in a .env file

  • bcryptjs → Hashes passwords so we never store them as plain text

  • jsonwebtoken → Creates tokens so users stay logged in

  • zod → Validates data (makes sure inputs are correct)

  • cors → Allows our React app to talk to our server

  • nodemon → 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 .env file or push it to GitHub. Add .env to your .gitignore file.


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;

💡 safeParse tries to validate the data but doesn't throw an error if it fails — it returns an object with success: true/false instead. 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/register with 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 than fetch)

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

  1. Add styling — Use Tailwind CSS or a component library like shadcn/ui

  2. Add refresh tokens — So users don't get logged out after 7 days unexpectedly

  3. Deploy the backend — Try Render or Railway

  4. Deploy the frontend — Try Vercel (free and super easy)

  5. Add search — Filter notes by title using MongoDB queries

  6. Learn TypeScript — Add type safety to your JavaScript


Resources


Happy coding! Remember — every expert was once a beginner. The fact that you're building full-stack apps already puts you ahead. Keep going.