Skip to content
Build a Full-Stack Note-Taking App with React, Express, and MongoDB (Step by Step)

Build a Full-Stack Note-Taking App with React, Express, and MongoDB (Step by Step)

DodaTech Updated Jun 20, 2026 8 min read

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

Step 1: Project Setup

mkdir note-app
cd note-app
mkdir server client

Backend setup

cd server
npm init -y
npm install express mongoose cors dotenv
npm install -D nodemon
mkdir models routes

Frontend setup

cd ../client
npx create-react-app .
npm install react-router-dom react-markdown remark-gfm

Step 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=5000

Step 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

How do I add authentication?
Add a User model with bcrypt password hashing and JWT tokens. Protect routes with an Express middleware that verifies the token before allowing CRUD operations. Each note should store a userId field to scope access.
Can I use SQL instead of MongoDB?
Yes. The same API pattern works with PostgreSQL + Prisma or SQLite. Replace Mongoose methods with Prisma queries. The Express routes stay the same — only the data layer changes.
How do I deploy this?
Build the React app (npm run build), serve the static files from Express, and deploy to Railway, Render, or a VPS. Use MongoDB Atlas for the database. Set environment variables for MONGO_URI and PORT.

Next Steps

  • Add JWT authentication with OAuth 2.0
  • Containerize with Docker
  • Add real-time sync with WebSocket
  • Explore the MongoDB tutorial for advanced aggregation pipelines
  • Check the REST API project for more API design patterns

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro