DEV Community

tommy
tommy

Posted on

Building a JSON Formatter: Zero-Click Auto-Format and the Clipboard API Trap

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
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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 */ }
    // ...
  }
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>`;
}
Enter fullscreen mode Exit fullscreen mode

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>`
Enter fullscreen mode Exit fullscreen mode

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>`
Enter fullscreen mode Exit fullscreen mode

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 dangerouslySetInnerHTML content, either add them to your safelist or 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);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Domains are separated by subdomain:

puremark.app               -> Landing page
annotate.puremark.app      -> Image annotation tool
json.puremark.app          -> JSON Formatter
Enter fullscreen mode Exit fullscreen mode

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


References

Top comments (0)