All Tutorials
react
typescript
frontend
vite

Build a Markdown Notes App with React and TypeScript

A code-along tutorial: build a single-page markdown notes app with React, TypeScript, and Vite. Sidebar list, split-pane editor with live preview, and localStorage persistence. No backend, no signup. Every file is here to paste and run.

12 min read2026-07-29

The short answer

Scaffold a React + TypeScript app with Vite, add react-markdown for the preview, and store notes in localStorage so they survive a refresh. The whole app is a handful of small typed components: a NoteList sidebar, a split-pane NoteEditor with a raw markdown textarea next to a live-rendered preview, and an App that holds the notes array and wires them together. There is no server, no database, and no API key. Setup takes about ten minutes and every file is on this page to paste into a fresh Vite project.

  • Setup time: about 10 minutes
  • Stack: React 18, TypeScript, Vite
  • Runtime dependencies: two (react-dom and react-markdown); everything else is dev tooling
  • No backend, no database, no API key, no signup
  • Deployable free on any static host (Vercel, Netlify, GitHub Pages)

What you'll build

By the end of this tutorial you'll have a working markdown notes app running in your browser: a sidebar that lists your notes, a split-pane editor with a raw markdown textarea on one side and a live-rendered preview on the other, and persistence to localStorage so your notes survive a page refresh. No server, no database, no API key, and no signup.

Every file you need is on this page. Paste each one into a fresh Vite project, follow the setup steps, and it runs. There is no repo to clone and no hidden files. The tutorial also explains the decisions behind the code, so you can change it without guessing.

Why build this (the honest case for a "just React" project)

Most React tutorials reach for a framework like Next.js or a state library like Redux on the first page. This one deliberately stays framework-minimal: plain React plus Vite, and nothing else for state. The point is to see what React actually gives you before you add anything heavier. By the end you will have used component state (useState), props to pass data down, controlled inputs, a small custom hook, and effects through the hook. Those are the core ideas every larger React tool is built on top of.

Here is the honest limitation up front: because everything lives in localStorage, your notes are tied to one browser on one device. There is no sync across devices, no sharing, and no rich-text formatting beyond what markdown gives you. That is the correct trade for a project whose job is to teach React fundamentals, not to be a product. When you outgrow it, the two next steps are clear: add a real backend so notes live on a server (our REST API tutorial is the natural follow-on), or put the app behind a hosted sync service. This tutorial gets you the foundation those steps build on.

Setup

Scaffold a fresh React + TypeScript project with Vite, then add the one runtime library this app needs, react-markdown, for turning note text into formatted HTML:

bash
npm create vite@latest notes-app -- --template react-ts
cd notes-app
npm install react-markdown
npm run dev

npm create vite scaffolds the project, including react, react-dom, typescript, vite, and @vitejs/plugin-react in package.json. The only package you add by hand is react-markdown. Everything else in the app is built-in React. Vite's template already gives you a working package.json; the scripts block looks like this:

json
{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  }
}

npm run dev starts the dev server (usually at http://localhost:5173) with hot reload. npm run build type-checks and produces a static bundle in dist/ you can deploy anywhere. The files below all go in the src/ directory Vite created. The template ships a src/main.tsx that already renders <App />, so you do not need to touch it: you are only replacing App.tsx and adding a few new files next to it.

Define the Note type

Start with the shape of a single note. One typed interface, shared across every component, so the compiler catches a mismatch the moment you make one. Create src/types.ts:

typescript
export interface Note {
  id: string;
  title: string;
  body: string;
  updatedAt: number;
}

id is a unique string (we generate it with the browser's built-in crypto.randomUUID()). title and body are the editable text. updatedAt is a millisecond timestamp so we can show when a note last changed and, later, sort by it. No any, no loose objects: every component below takes and returns this exact type.

A small localStorage hook

Notes need to survive a refresh, and for a single-user local app the simplest correct place to keep them is localStorage. Rather than sprinkle localStorage.getItem/setItem calls through the app, wrap it in one typed hook that behaves like useState but reads its initial value from storage and writes every change back. Create src/useLocalStorage.ts:

typescript
import { useState } from "react";

export function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    try {
      const stored = window.localStorage.getItem(key);
      return stored ? (JSON.parse(stored) as T) : initialValue;
    } catch {
      // corrupt JSON or storage blocked: fall back to the default
      return initialValue;
    }
  });

  const setStored = (next: T | ((prev: T) => T)) => {
    setValue((prev) => {
      const resolved =
        typeof next === "function" ? (next as (p: T) => T)(prev) : next;
      try {
        window.localStorage.setItem(key, JSON.stringify(resolved));
      } catch {
        // storage full or private mode: keep the in-memory value anyway
      }
      return resolved;
    });
  };

  return [value, setStored] as const;
}

Two things make this the right tool for the job and not a shortcut. First, the return type is [value, setStored] as const, the same tuple shape as useState, so it drops into a component the exact way useState does and TypeScript infers the types from initialValue. Second, setStored accepts either a new value or an updater function ((prev) => next), which matters because we update notes based on the current array. The try/catch blocks are not decoration: JSON.parse throws on corrupt data, and setItem throws when storage is full or blocked in private browsing. Swallowing those keeps the app usable instead of crashing on an edge case a learner would otherwise hit weeks later.

The note list

The sidebar is a controlled, presentational component: it takes the notes and the current selection as props and reports clicks back up through callbacks. It holds no state of its own. Create src/NoteList.tsx:

tsx
import type { Note } from "./types";

interface NoteListProps {
  notes: Note[];
  selectedId: string | null;
  onSelect: (id: string) => void;
  onCreate: () => void;
}

export function NoteList({ notes, selectedId, onSelect, onCreate }: NoteListProps) {
  return (
    <aside className="note-list">
      <button className="new-note" onClick={onCreate}>
        + New note
      </button>
      <ul>
        {notes.map((note) => (
          <li key={note.id}>
            <button
              className={note.id === selectedId ? "note active" : "note"}
              onClick={() => onSelect(note.id)}
            >
              <span className="note-title">{note.title || "Untitled note"}</span>
              <span className="note-date">
                {new Date(note.updatedAt).toLocaleDateString()}
              </span>
            </button>
          </li>
        ))}
      </ul>
    </aside>
  );
}

The key={note.id} on each <li> is how React tracks list items across re-renders: use the stable note id, never the array index, or React will reuse the wrong DOM node when the order changes. The title || "Untitled note" fallback keeps a brand-new, empty note from rendering a blank row. Everything this component knows comes from props, which is what makes it easy to reason about and trivial to test.

The editor with live preview

This is the core of the app. A controlled <textarea> holds the raw markdown, and a <ReactMarkdown> pane next to it renders that same text as formatted HTML. Because both read from the same piece of React state, the preview updates on every keystroke with no extra wiring. Create src/NoteEditor.tsx:

tsx
import ReactMarkdown from "react-markdown";
import type { Note } from "./types";

interface NoteEditorProps {
  note: Note | null;
  onChange: (patch: { title: string; body: string }) => void;
}

export function NoteEditor({ note, onChange }: NoteEditorProps) {
  if (!note) {
    return (
      <section className="editor empty">
        <p>Select a note, or create a new one to start writing.</p>
      </section>
    );
  }

  return (
    <section className="editor">
      <input
        className="title-input"
        value={note.title}
        placeholder="Note title"
        onChange={(e) => onChange({ title: e.target.value, body: note.body })}
      />
      <div className="split">
        <textarea
          className="source"
          value={note.body}
          placeholder="Write markdown here..."
          onChange={(e) => onChange({ title: note.title, body: e.target.value })}
        />
        <div className="preview">
          <ReactMarkdown>{note.body}</ReactMarkdown>
        </div>
      </div>
    </section>
  );
}

The "aha" here is that there is no live-preview library. The textarea's value is note.body, and its onChange reports the new text upward; the parent updates state, React re-renders, and <ReactMarkdown>{note.body}</ReactMarkdown> renders the fresh text. That round trip happens on every keystroke because it is all controlled React state. react-markdown is doing exactly one job, turning the markdown string into safe HTML elements, which is why we reach for it instead of hand-writing a parser (more on that in the FAQs). The early return for a null note keeps the render logic below simple: past that guard, note is always present.

Wiring it together in App.tsx

App is the one component that owns state. It holds the notes array (through the useLocalStorage hook), tracks which note is selected, and passes handlers down to the list and the editor. Replace the generated src/App.tsx with this:

tsx
import { useState } from "react";
import type { Note } from "./types";
import { useLocalStorage } from "./useLocalStorage";
import { NoteList } from "./NoteList";
import { NoteEditor } from "./NoteEditor";
import "./App.css";

function createNote(): Note {
  return {
    id: crypto.randomUUID(),
    title: "Untitled note",
    body: "# New note",
    updatedAt: Date.now(),
  };
}

export default function App() {
  const [notes, setNotes] = useLocalStorage<Note[]>("notes-app.notes", []);
  const [selectedId, setSelectedId] = useState<string | null>(
    notes[0]?.id ?? null,
  );

  const selected = notes.find((n) => n.id === selectedId) ?? null;

  function handleCreate() {
    const note = createNote();
    setNotes((prev) => [note, ...prev]);
    setSelectedId(note.id);
  }

  function handleUpdate(patch: { title: string; body: string }) {
    if (!selected) return;
    setNotes((prev) =>
      prev.map((n) =>
        n.id === selected.id ? { ...n, ...patch, updatedAt: Date.now() } : n,
      ),
    );
  }

  return (
    <div className="app">
      <NoteList
        notes={notes}
        selectedId={selectedId}
        onSelect={setSelectedId}
        onCreate={handleCreate}
      />
      <NoteEditor note={selected} onChange={handleUpdate} />
    </div>
  );
}

Read the data flow once and the whole app clicks. handleCreate builds a note, puts it at the front of the array, and selects it. handleUpdate finds the selected note and returns a new array with that one note replaced, using the spread { ...n, ...patch, updatedAt: Date.now() }: it never mutates the old note in place, which is the rule React state depends on. selected is derived from notes and selectedId on every render, so there is no duplicate copy of the note to keep in sync. Because setNotes comes from useLocalStorage, every one of these changes is written to storage automatically. That is the entire app: three small components and one hook.

The import "./App.css" line pulls in styling. Vite's template ships an App.css you can replace with the split-pane layout below (optional, but it makes the two panes sit side by side). Create or overwrite src/App.css:

css
.app {
  display: flex;
  height: 100vh;
  font-family: system-ui, sans-serif;
}

.note-list {
  width: 240px;
  border-right: 1px solid #ddd;
  padding: 12px;
  overflow-y: auto;
}

.note-list ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

.note-list .note {
  width: 100%;
  text-align: left;
  padding: 8px;
  border: none;
  background: none;
  cursor: pointer;
  display: flex;
  flex-direction: column;
}

.note-list .note.active {
  background: #eef;
  border-radius: 6px;
}

.note-date {
  font-size: 12px;
  color: #888;
}

.editor {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 12px;
}

.title-input {
  font-size: 20px;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 6px;
  margin-bottom: 12px;
}

.split {
  display: flex;
  flex: 1;
  gap: 12px;
  min-height: 0;
}

.source,
.preview {
  flex: 1;
  overflow-y: auto;
  border: 1px solid #ddd;
  border-radius: 6px;
  padding: 12px;
}

.source {
  font-family: ui-monospace, monospace;
  resize: none;
}

Shore up your React first

If any of this felt shaky, the fastest free way to solidify React is Scrimba's interactive Learn React course, where you edit components inside the lesson. For the ranked rundown of every free option, see our best free React courses guide, and keep the React cheat sheet open while you build.

Running and testing it

Start the dev server if it is not already running:

bash
npm run dev

Open the local URL Vite prints (usually http://localhost:5173) and walk through the app to confirm every piece works:

  • Click + New note. A note appears at the top of the sidebar and opens in the editor.
  • Type in the title field. The sidebar row updates as you type.
  • Type markdown in the left textarea (try # A heading, **bold**, and a - list item). The right pane renders it live on every keystroke.
  • Create a second note and click between the two. The editor swaps to the one you selected.
  • Refresh the page. Your notes are still there: that is localStorage doing its job, with no backend involved.

If the preview does not update, check that the textarea's value is note.body and its onChange passes e.target.value: a controlled input that forgets one of those is the usual cause. If a refresh loses your notes, open the browser dev tools, look under Application, Local Storage, and confirm a notes-app.notes key is being written.

What to build next

You have a working app and, more usefully, a clear mental model of how React state flows down and events flow up. Three honest next steps extend it without a rewrite.

Markdown export. Add a "Download" button that turns the current note's body into a .md file. It is a few lines: build a Blob from note.body, create an object URL, and click a temporary anchor. No new dependency needed.

Search over titles. Add a text input above the list and filter notes by title before rendering. Because NoteList is already driven entirely by props, this is a change in App alone: filter the array you pass in.

A real backend. The honest limitation from the top of this tutorial (notes stuck in one browser) goes away the moment notes live on a server. The next tutorial in this series, build a REST API with Express and TypeScript, gives you exactly that API. Swap the useLocalStorage hook for fetch calls to it and your notes sync anywhere you sign in. That is the natural graduation from this project.

For the full sequenced route into frontend work, see /learn/frontend and /roadmap/frontend.

Frequently asked questions

Do I need a backend to build a notes app?

No. This entire app runs in the browser with no server, no database, and no API key. Notes are stored in localStorage, which the browser gives every site for free. You only need a backend when you want notes to sync across devices or be shared between users, and even then the React code you write here barely changes: you swap the useLocalStorage hook for fetch calls to your API.

Why use react-markdown instead of writing my own markdown parser?

Markdown looks simple until you handle nested lists, code blocks, links, escaping, and the security of rendering user text as HTML. react-markdown is the widely-used, well-tested library that already handles all of that and escapes HTML safely by default, so a note that contains a script tag renders as text instead of running. Writing your own parser is a fun exercise but the wrong choice for an app you actually use; it is more code to maintain and easy to get subtly wrong.

Is localStorage safe to use for real data?

For a single-user, single-device app like this, yes, with two caveats. localStorage is not encrypted and any JavaScript running on the page can read it, so do not store passwords or secrets there. It is also capped at roughly 5 MB per origin, which is plenty for text notes but not for images or files. For personal markdown notes on your own machine it is a good fit. For anything sensitive or shared, move the data to a backend.

Can I deploy this for free?

Yes. Run npm run build to produce a static bundle in dist/, then drop it on any static host. Vercel, Netlify, and GitHub Pages all have free tiers that serve a Vite build with no configuration beyond pointing them at your repo. Because there is no backend, there is nothing to pay for or keep running: it is just static files.

What's the difference between this and using a state management library like Redux?

Redux and similar libraries exist to manage state that is shared across many distant components in a large app. This app has one owner of state (App) and passes it down through props, which plain React handles cleanly. Reaching for Redux here would add a dependency and boilerplate to solve a problem you do not have. The rule of thumb: start with useState and lift state up as this tutorial does, and only add a state library when prop-passing genuinely becomes painful in a bigger app.

Why Vite instead of Create React App?

Vite is the current standard for scaffolding a plain React app: it starts fast, has instant hot reload, and its react-ts template gives you TypeScript configured out of the box. Create React App is no longer actively recommended. Vite is what most new React projects use in 2026, which is exactly why we use it here.

Keep going on FreeCodingCourses