When an API response comes back as a single line, what do you reach for?
Open jsonformatter.org, paste, click "Format."
Three steps I was repeating dozens of times a week. Then in November 2025, watchTowr Labs revealed that jsonformatter.org and codebeautify.org had leaked over 80,000 AWS keys and API secrets -- because every piece of JSON you paste gets sent to their servers. The act of formatting JSON online was itself a security risk.
So I built a tool where you copy JSON, open a tab, and it's already formatted. Zero server transmission.
Try it now -- copy any JSON to your clipboard and open this link:
https://json.puremark.app
This article documents the traps I hit along the way: Clipboard API quirks, syntax highlighting without external libraries, and Tailwind's purge problem with innerHTML. If you're building browser-based developer tools, this might save you some debugging hours.
Tech Stack
React 19 + TypeScript + Vite 6 + Tailwind CSS v4 + vite-plugin-pwa
A simple SPA -- no state management library needed. useState + useCallback was more than enough.
1. Auto-Reading the Clipboard -- Clipboard API Behavior
Basic Implementation
On page load, read the clipboard. If the content looks like JSON, auto-format it.
const readClipboard = useCallback(() => {
navigator.clipboard.readText().then((text) => {
if (!text.trim()) return;
const t = text.trimStart();
if (t[0] === '{' || t[0] === '[' || t[0] === '"') {
applyInput(text);
}
}).catch(() => {});
}, [applyInput]);
useEffect(() => {
readClipboard();
const onVisible = () => {
if (document.visibilityState === 'visible') readClipboard();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [readClipboard]);
Using visibilitychange means the clipboard is re-read every time the tab regains focus. Copy JSON in another tab, switch back, and it's already formatted.
Gotcha #1: Don't gate auto-detection on JSON.parse success
My first implementation only auto-applied text that successfully parsed:
// Bad: first implementation
const r = processJson(text);
if (!r.error) applyInput(text);
This meant JSON with syntax errors (trailing commas, mismatched quotes, etc.) was silently ignored. From the user's perspective: "I copied it, but nothing happened."
The fix was a simple heuristic based on the first character:
// Good: fixed version
const t = text.trimStart();
if (t[0] === '{' || t[0] === '[' || t[0] === '"') {
applyInput(text); // Apply even broken JSON -> show error message
}
Text starting with {, [, or " is very likely JSON. URLs and plain text almost never trigger false positives. Broken JSON gets applied and displayed with a line-numbered error message.
Lesson learned: If you gate auto-detection on "is the data valid?", you block users who want to fix invalid data.
Gotcha #2: The Clipboard API "shouldn't work" on page load -- but it does
navigator.clipboard.readText() is supposed to require a user gesture (click, etc.) or it throws a permission error. MDN says so.
But in Chrome, calling it on page load triggers the permission prompt, and if the user grants it, the read succeeds. This was the behavior I'd been relying on from the start.
At one point I removed the on-mount call because "the spec says it shouldn't work." Auto-read broke completely.
Lesson learned: Don't delete working code because "the spec says it shouldn't work." If you must, test on real devices first.
Fallback: Paste Event
For environments where the Clipboard API isn't available, a paste event listener provides a fallback. Ctrl+V / Cmd+V works regardless of browser permissions.
useEffect(() => {
const onPaste = (e: ClipboardEvent) => {
if ((e.target as HTMLElement)?.tagName === 'TEXTAREA') return;
const text = e.clipboardData?.getData('text');
if (text?.trim()) applyInput(text);
};
document.addEventListener('paste', onPaste);
return () => document.removeEventListener('paste', onPaste);
}, [applyInput]);
Paste events inside the TEXTAREA are handled by React's onChange, so the document-level listener skips them to avoid duplication.
2. Syntax Highlighting -- No External Libraries
highlight.js or Prism.js would make this easy, but they add tens of KBs to the bundle. For JSON-only highlighting, a custom implementation was more than sufficient.
One Regex to Classify All Tokens
content.replace(
/("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(true|false|null)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}\[\]])|([,:])/g,
(match, key, str, keyword, num, bracket, punct) => {
if (key) return `<span class="text-sky-300">${escHtml(key)}</span>:`;
if (str) return `<span class="text-emerald-400">${escHtml(str)}</span>`;
if (keyword === 'true' || keyword === 'false')
return `<span class="text-orange-400">${keyword}</span>`;
if (keyword === 'null')
return `<span class="text-zinc-500">null</span>`;
if (num) return `<span class="text-blue-400">${num}</span>`;
if (bracket) { /* depth-based coloring */ }
// ...
}
);
The key insight for distinguishing keys from string values: keys are followed by :. The regex capture group ("...")\s*: separates them cleanly.
Depth-Based Bracket Coloring
To improve readability of deeply nested JSON, {} and [] get colored by nesting depth, cycling through 5 colors:
const BRACKET_COLORS = [
'text-yellow-300', 'text-pink-400', 'text-blue-400',
'text-green-400', 'text-orange-400',
];
// Opening bracket: use color at current depth, then depth++
// Closing bracket: depth-- then use color at that depth
Indent Guides (Vertical Lines)
Vertical lines at each indent level make nesting depth visually obvious:
for (let i = 0; i < indentSpaces; i++) {
guideHtml += `<span style="display:inline-block;width:1ch;position:relative">
<span style="position:absolute;top:0;bottom:0;left:0.45ch;
border-left:1px solid #71717a"></span> </span>`;
}
Notice the inline styles here. That's important -- explained in the next section.
3. Tailwind CSS + dangerouslySetInnerHTML: The Purge Problem
The syntax-highlighted HTML is injected via dangerouslySetInnerHTML. If you use Tailwind classes in that HTML, they get purged at build time and have no effect.
// Bad: doesn't work after build
`<span class="border-l border-zinc-500">...</span>`
Tailwind v4 detects classes by scanning source files, but it can't find classes inside strings passed to dangerouslySetInnerHTML.
The fix is straightforward: use inline styles for injected HTML.
// Good: inline styles always apply
`<span style="border-left:1px solid #71717a">...</span>`
The syntax highlighting color classes (text-sky-300, etc.) survive the purge because they're also used in the JSX return. Only the indent guide lines needed inline styles.
Lesson learned: If you're using Tailwind classes in
dangerouslySetInnerHTMLcontent, either add them to yoursafelistor use inline styles.All of this -- syntax highlighting, indent guides, and bracket coloring -- implemented with zero external libraries. See it in action at json.puremark.app.
4. Error Line Numbers -- Cross-Browser Differences
Extracting line and column numbers from JSON.parse errors:
catch (e) {
const msg = (e as Error).message;
const lineMatch = msg.match(/line (\d+)/i);
const colMatch = msg.match(/column (\d+)/i);
// ...
}
But error message formats differ across browsers:
| Browser | Error message example | Line number |
|---|---|---|
| Firefox | unexpected character at line 3 column 5 |
Extractable |
| Chrome | Unexpected token } in JSON at position 42 |
Position only |
| Safari | JSON Parse error: Expected '}' |
None |
Firefox gives precise line and column numbers. Chrome only provides a character offset (position). For better Chrome support, you'd need to scan the input text and convert the position to a line number.
Monorepo Structure
This JSON Formatter lives in the same monorepo as PureMark Annotate, an image annotation tool:
puremark/
apps/
annotate/ # Image annotation tool
json-formatter/ # JSON Formatter (this article)
packages/
ui/ # Shared UI components (future)
package.json # npm workspaces
Each app gets its own Cloudflare Pages project with a targeted build command:
# json-formatter build config
Build command: cd apps/json-formatter && npm install && npm run build
Build output: apps/json-formatter/dist
Domains are separated by subdomain:
puremark.app -> Landing page
annotate.puremark.app -> Image annotation tool
json.puremark.app -> JSON Formatter
Summary
| Challenge | Solution |
|---|---|
| Clipboard auto-read | Clipboard API (on mount + visibilitychange) + paste event fallback |
| JSON detection | First-character heuristic ({, [, "). Don't gate on JSON.parse success |
| Syntax highlighting | Single regex classifying all tokens. No external libraries |
| Tailwind + innerHTML | Use inline styles for injected HTML |
| Error line numbers |
/line (\d+)/i extraction. Chrome only gives position (room for improvement) |
When you build developer tools that solve your own pain, implementation decisions come fast. When you're unsure about a feature, ask: "Would I use this every day?" That keeps things focused.
PureMark JSON Formatter -- copy JSON, open a tab, done. Zero server transmission.
https://json.puremark.app
Top comments (0)