Build a Full-Stack Note-Taking App with React, Express, and MongoDB (Step by Step)
Build a full-stack note-taking application using React, Node.js with Express, and MongoDB that supports markdown rendering, tagging, and full-text search.
What You’ll Build
You’ll build a note-taking app where users can create, edit, delete, and search notes with markdown formatting and tag organization. Notes persist in MongoDB, render live markdown previews, and filter by tag or search term. This same architecture powers internal documentation systems at DodaTech, including the runbook system used by Durga Antivirus Pro operations.
Why Full-Stack Note Apps Matter
Note-taking is one of the most common full-stack projects because it touches every layer: a database for persistence (MongoDB), an API for CRUD operations (Express), and a reactive UI for real-time updates (React). Every modern SaaS product — from Notion to Google Docs — follows this same three-layer pattern. Building one teaches you database modeling, API design, authentication patterns, and state management in a single project.
Prerequisites
- React fundamentals
- Node.js and Express.js basics
- MongoDB running locally or via Docker
- JavaScript ES6+ familiarity
Step 1: Project Setup
mkdir note-app
cd note-app
mkdir server clientBackend setup
cd server
npm init -y
npm install express mongoose cors dotenv
npm install -D nodemon
mkdir models routesFrontend setup
cd ../client
npx create-react-app .
npm install react-router-dom react-markdown remark-gfmStep 2: MongoDB Model
// server/models/Note.js
const mongoose = require('mongoose');
const noteSchema = new mongoose.Schema({
title: { type: String, required: true, default: 'Untitled' },
content: { type: String, default: '' },
tags: [{ type: String }],
pinned: { type: Boolean, default: false },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date, default: Date.now }
});
noteSchema.index({ title: 'text', content: 'text', tags: 'text' });
module.exports = mongoose.model('Note', noteSchema);The text index on title, content, and tags enables MongoDB’s full-text search — no need for Elasticsearch at this scale.
Step 3: Express API Routes
// server/routes/notes.js
const express = require('express');
const router = express.Router();
const Note = require('../models/Note');
// GET /api/notes — list all notes (with optional search + tag filter)
router.get('/', async (req, res) => {
const { search, tag } = req.query;
let query = {};
if (search) {
query.$text = { $search: search };
}
if (tag) {
query.tags = tag;
}
const notes = await Note.find(query).sort({ pinned: -1, updatedAt: -1 });
res.json(notes);
});
// GET /api/notes/:id — single note
router.get('/:id', async (req, res) => {
const note = await Note.findById(req.params.id);
if (!note) return res.status(404).json({ error: 'Note not found' });
res.json(note);
});
// POST /api/notes — create
router.post('/', async (req, res) => {
const note = new Note(req.body);
await note.save();
res.status(201).json(note);
});
// PUT /api/notes/:id — update
router.put('/:id', async (req, res) => {
const note = await Note.findByIdAndUpdate(
req.params.id,
{ ...req.body, updatedAt: Date.now() },
{ new: true }
);
if (!note) return res.status(404).json({ error: 'Note not found' });
res.json(note);
});
// DELETE /api/notes/:id — delete
router.delete('/:id', async (req, res) => {
const note = await Note.findByIdAndDelete(req.params.id);
if (!note) return res.status(404).json({ error: 'Note not found' });
res.json({ message: 'Deleted' });
});
module.exports = router;Step 4: Server Entry Point
// server/index.js
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
require('dotenv').config();
const app = express();
app.use(cors());
app.use(express.json());
mongoose.connect(process.env.MONGO_URI || 'mongodb://localhost:27017/noteapp')
.then(() => console.log('MongoDB connected'));
app.use('/api/notes', require('./routes/notes'));
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));# server/.env
MONGO_URI=mongodb://localhost:27017/noteapp
PORT=5000Step 5: React Frontend — API Service
// client/src/services/api.js
const API = 'http://localhost:5000/api/notes';
export async function fetchNotes(search = '', tag = '') {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (tag) params.set('tag', tag);
const res = await fetch(`${API}?${params}`);
return res.json();
}
export async function fetchNote(id) {
const res = await fetch(`${API}/${id}`);
return res.json();
}
export async function createNote(note) {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note),
});
return res.json();
}
export async function updateNote(id, note) {
const res = await fetch(`${API}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(note),
});
return res.json();
}
export async function deleteNote(id) {
const res = await fetch(`${API}/${id}`, { method: 'DELETE' });
return res.json();
}Expected output: Each function maps to one Express endpoint. They return parsed JSON or throw on network errors.
Step 6: Note Editor Component
// client/src/components/NoteEditor.js
import React, { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { fetchNote, updateNote, createNote } from '../services/api';
export default function NoteEditor({ noteId, onSave }) {
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [preview, setPreview] = useState(false);
useEffect(() => {
if (noteId) {
fetchNote(noteId).then(note => {
setTitle(note.title);
setContent(note.content);
setTagsInput((note.tags || []).join(', '));
});
}
}, [noteId]);
const handleSave = async () => {
const tags = tagsInput.split(',').map(t => t.trim()).filter(Boolean);
const payload = { title, content, tags };
if (noteId) {
await updateNote(noteId, payload);
} else {
await createNote(payload);
}
onSave();
};
return (
<div className="editor">
<div className="editor-toolbar">
<input
className="title-input"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="Note title..."
/>
<input
className="tags-input"
value={tagsInput}
onChange={e => setTagsInput(e.target.value)}
placeholder="Tags (comma separated)"
/>
<button onClick={() => setPreview(!preview)}>
{preview ? 'Edit' : 'Preview'}
</button>
<button className="btn-save" onClick={handleSave}>Save</button>
</div>
{preview ? (
<div className="markdown-preview">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
) : (
<textarea
className="editor-textarea"
value={content}
onChange={e => setContent(e.target.value)}
placeholder="Write your note in markdown..."
/>
)}
</div>
);
}Step 7: Search and Note List
// client/src/components/NoteList.js
import React, { useState, useEffect, useCallback } from 'react';
import { fetchNotes, deleteNote } from '../services/api';
export default function NoteList({ onSelect, activeId, refresh }) {
const [notes, setNotes] = useState([]);
const [search, setSearch] = useState('');
const [tagFilter, setTagFilter] = useState('');
const loadNotes = useCallback(async () => {
const data = await fetchNotes(search, tagFilter);
setNotes(data);
}, [search, tagFilter]);
useEffect(() => { loadNotes(); }, [loadNotes, refresh]);
const allTags = [...new Set(notes.flatMap(n => n.tags || []))];
return (
<div className="sidebar">
<div className="sidebar-header">
<h2>Notes</h2>
<button className="btn-new" onClick={() => onSelect(null)}>+ New</button>
</div>
<input
className="search-input"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search notes..."
/>
{allTags.length > 0 && (
<div className="tag-filter">
{allTags.map(tag => (
<button
key={tag}
className={`tag ${tagFilter === tag ? 'active' : ''}`}
onClick={() => setTagFilter(tagFilter === tag ? '' : tag)}
>
{tag}
</button>
))}
</div>
)}
<div className="note-list">
{notes.map(note => (
<div
key={note._id}
className={`note-card ${note._id === activeId ? 'active' : ''}`}
onClick={() => onSelect(note._id)}
>
<h4>{note.title}</h4>
<p>{note.content.substring(0, 100)}</p>
<div className="note-meta">
<span>{new Date(note.updatedAt).toLocaleDateString()}</span>
{note.tags?.map(t => <span key={t} className="tag-badge">{t}</span>)}
</div>
</div>
))}
</div>
</div>
);
}Architecture
sequenceDiagram
participant User as Browser (React)
participant API as Express Server
participant DB as MongoDB
User->>API: GET /api/notes?search=keyword&tag=work
API->>DB: db.notes.find({$text:{$search:"keyword"}, tags:"work"})
DB-->>API: Matching notes
API-->>User: JSON array
User->>API: POST /api/notes (title, content, tags)
API->>DB: db.notes.insertOne({...})
DB-->>API: Created note
API-->>User: 201 + note object
User->>API: PUT /api/notes/abc123 (updated content)
API->>DB: db.notes.updateOne({_id:abc123}, {$set:{...}})
DB-->>API: Updated note
API-->>User: Updated note
User->>API: DELETE /api/notes/abc123
API->>DB: db.notes.deleteOne({_id:abc123})
DB-->>API: Deleted
API-->>User: 200 + message
Common Errors
1. MongoDB connection refused
MongoDB isn’t running. Start it with mongod or use Docker: docker run -d -p 27017:27017 mongo. Check the MONGO_URI in .env matches your instance.
2. CORS errors in the browser
The React dev server runs on port 3000, Express on port 5000. Browsers block cross-origin requests. Ensure cors() middleware is registered before routes in server/index.js. If credentials are needed, use cors({ origin: 'http://localhost:3000', credentials: true }).
3. Text search returns no results
MongoDB’s $text search requires a text index. Verify the index exists with db.notes.getIndexes(). The schema creates it, but if you already had documents before adding the index, the index might not be built. Wait for the background build or force it with Note.createIndexes().
4. React markdown not rendering HTML
By default, react-markdown does not render raw HTML for security. Use rehype-raw plugin if you need HTML in markdown. The code above uses remark-gfm for tables, strikethrough, and task lists.
5. Note not updating after save
The updatedAt field only updates if explicitly set. The PUT route sets updatedAt: Date.now(). If you add new fields later, remember to include them in the update payload — findByIdAndUpdate only sets the fields in the second argument.
Practice Questions
1. Why do we use a text index instead of a regex search?
Text indexes tokenize and stem words, supporting relevance scoring and partial matches. Regex searches ({ title: /keyword/i }) are slower and cannot use indexes efficiently for partial word matches.
2. What happens if two users edit the same note simultaneously?
The last PUT request wins — MongoDB has no built-in locking. For production, add optimistic concurrency with a version field. Increment it on each update and reject stale writes.
3. How does the tag filter combine with text search?
The query object includes both $text and tags conditions. MongoDB applies both filters with an implicit AND. Documents must match both the search term and the tag to be returned.
4. Challenge: Note version history
Create a separate versions collection. Before each update, save the current note state as a version document. Add a “History” panel showing all versions with timestamps and a “Restore” button.
5. Challenge: Collaborative editing
Add WebSocket support. When a note is opened, join a room. Broadcast content changes to other viewers of the same note. Use Operational Transform (OT) or Conflict-Free Replicated Data Types (CRDTs) to resolve conflicts.
FAQ
Next Steps
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro