Build a Serverless Text Editor with URL Storage – textarea.my

Category: Javascript , Text | December 25, 2025
Authorantonmedv
Last UpdateDecember 25, 2025
LicenseMIT
Tags
Views80 views
Build a Serverless Text Editor with URL Storage – textarea.my

textarea.my is an open-source text editor that stores text content in the URL hash using deflate compression. It runs entirely in the browser with zero server dependencies.

The text editor uses contenteditable elements, the Compression Streams API, and debounced auto-save to create a functional note-taking experience. It’s great for developers building shareable text tools, browser-based documentation, or offline-first applications.

Features:

  • URL-based storage: Compresses content with deflate-raw and encodes it in the URL hash.
  • Automatic saving: Debounces input events to 500ms before triggering save operations.
  • LocalStorage fallback: Maintains a backup copy in localStorage for persistence across sessions.
  • Dark mode: Respects the user’s system color scheme preference via CSS media queries.
  • Style persistence: Saves inline styles applied to the editor element in the URL.
  • Dynamic title updates: Parses markdown-style headers to set the page title.

Use Cases:

  • Quick note sharing: Share code snippets or technical notes by copying the URL. The recipient gets the full content loaded instantly.
  • Offline documentation: Create browser-bookmarkable reference documents that load from the URL hash.
  • Zero-infrastructure prototyping: Build text-based features during the design phase before backend APIs exist.

How To Use It:

1. Create a contenteditable element in your HTML. The plaintext-only attribute limits formatting to plain text.

<!-- The main editor element -->
<article contenteditable="plaintext-only" spellcheck></article>

2. The core JavaScript logic handles compression, state management, and event listeners.

// Select the contenteditable element
const article = document.querySelector('article')
// Listen for user input and trigger debounced save
article.addEventListener('input', debounce(500, save))
// Load content when the page loads
addEventListener('DOMContentLoaded', load)
// Load content when the URL hash changes
addEventListener('hashchange', load)
// Load function: retrieves content from URL hash or localStorage
async function load() {
  try {
    // Check if URL has a hash (shared content)
    if (location.hash !== '') {
      await set(location.hash)
    } else {
      // Fall back to localStorage
      await set(localStorage.getItem('hash') ?? '')
      
      // If content exists, update URL without creating history entry
      if (article.textContent) {
        history.replaceState({}, '', await get())
      }
      
      // Focus the editor for immediate typing
      article.focus()
    }
  } catch (e) {
    // Handle decompression errors by resetting content
    article.textContent = ''
    article.removeAttribute('style')
  }
  
  // Update page title based on content
  updateTitle()
}
// Save function: compresses content and updates URL + localStorage
async function save() {
  const hash = await get()
  
  // Update URL only if hash has changed
  if (location.hash !== hash) {
    history.replaceState({}, '', hash)
  }
  
  // Attempt to save to localStorage (may fail if quota exceeded)
  try { 
    localStorage.setItem('hash', hash) 
  } catch (e) {
    // Silent fail - URL storage remains functional
  }
  
  // Update page title after save
  updateTitle()
}
// Set function: decompresses hash and populates editor
async function set(hash) {
  // Decompress the base64-encoded hash
  const [content, style] = (await decompress(hash.slice(1))).split('\x00')
  
  // Set the text content
  article.textContent = content
  
  // Apply saved styles if they exist
  if (style) {
    article.setAttribute('style', style)
  }
}
// Get function: compresses current content into hash format
async function get() {
  const style = article.getAttribute('style')
  
  // Combine content and style with null separator
  const content = article.textContent + (style !== null ? '\x00' + style : '')
  
  // Return compressed hash with # prefix
  return '#' + await compress(content)
}
// Update page title from markdown-style header
function updateTitle() {
  // Match first line starting with # (markdown header)
  const match = article.textContent.match(/^\n*#(.+)\n/)
  
  // Set title to header text or default
  document.title = match?.[1] ?? 'Textarea'
}
// Compress function: converts string to base64-encoded deflate data
async function compress(string) {
  // Encode string to byte array
  const byteArray = new TextEncoder().encode(string)
  
  // Create deflate-raw compression stream
  const stream = new CompressionStream('deflate-raw')
  const writer = stream.writable.getWriter()
  
  // Write data and close stream
  writer.write(byteArray)
  writer.close()
  
  // Read compressed output
  const buffer = await new Response(stream.readable).arrayBuffer()
  
  // Convert to URL-safe base64 (replace + and / characters)
  return new Uint8Array(buffer)
    .toBase64()
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
}
// Decompress function: converts base64 to original string
async function decompress(b64) {
  // Convert URL-safe base64 back to standard format
  const byteArray = Uint8Array.fromBase64(
    b64.replace(/-/g, "+").replace(/_/g, "/")
  )
  
  // Create decompression stream
  const stream = new DecompressionStream('deflate-raw')
  const writer = stream.writable.getWriter()
  
  // Write compressed data and close
  writer.write(byteArray)
  writer.close()
  
  // Read decompressed output
  const buffer = await new Response(stream.readable).arrayBuffer()
  
  // Decode byte array back to string
  return new TextDecoder().decode(buffer)
}
// Debounce function: delays execution until user stops typing
function debounce(ms, fn) {
  let timer
  return (...args) => {
    clearTimeout(timer)
    timer = setTimeout(() => fn(...args), ms)
  }
}

3. You can set a custom page title by starting your document with a markdown-style header. The editor automatically extracts the first line after `#` and uses it as the page title.

# CSSScript.com
Content goes here

4. To add custom styling that persists in the URL, open DevTools and add inline styles to the article element:

document.querySelector('article').setAttribute('style', 
  'font-family: monospace; color: #00ff00; background: #000;'
)

FAQs:

Q: What happens when the compressed URL exceeds browser length limits?
A: Most browsers support URLs up to 2,000 characters. The deflate compression typically achieves 40-60% size reduction. This means you can store roughly 3,000-5,000 characters of plain text before hitting limits.

Q: Why does localStorage fail sometimes?
A: Browsers enforce storage quotas, typically 5-10MB per origin. If other scripts have filled localStorage, the save attempt throws an exception.

Q: Can I encrypt the content before compression?
A: Yes, but you’ll need to add encryption logic before the compression step. The Web Crypto API provides AES-GCM encryption. You’d encrypt the plaintext, then compress the encrypted bytes, then base64 encode. The recipient would need the decryption key to read the content.

You Might Be Interested In:


Leave a Reply