DEV Community

Mahdi Mirzadeh
Mahdi Mirzadeh

Posted on

Self-Hosting Excalidraw with Custom Fonts — No Extensions, No Cloud

I wanted to use Excalidraw with its signature hand-drawn font — but without depending on browser extensions or third-party services. That led me to create excalocal: a fully self-hosted Excalidraw server that runs entirely offline and supports custom fonts out of the box.

In this post, I’ll walk through the technical details behind excalocal:

  • How to serve a React application from a single Node.js file
  • How to inject and override fonts cleanly
  • How to support multiple named instances across platforms

🧠 The Problem

@DawidWraga’s excellent article demonstrated how to inject custom fonts into Excalidraw using browser extensions. I wanted something more integrated and self-contained, with these key requirements:

  • ✅ No browser extensions
  • ✅ Fully offline operation
  • ✅ Built-in custom fonts
  • ✅ Multiple named instances
  • ✅ Persistent storage per instance

🏗 Architecture Overview

The solution consists of three main components:

  1. HTTP Server – Serves static assets and the React app
  2. Font Override System – Injects custom CSS to replace Excalidraw’s default fonts
  3. Instance Manager – Tracks and manages multiple named drawing sessions

Let’s break each of these down.


1. Serving React from Node.js

The core challenge was to serve a complete React application without a separate build step. The key insight was to use require.resolve() to locate installed dependencies and serve their UMD bundles directly.

// Resolve library paths dynamically
const findDependencyPaths = () => {
  try {
    return {
      react: path.dirname(require.resolve('react/package.json')),
      reactDom: path.dirname(require.resolve('react-dom/package.json')),
      excalidraw: path.dirname(require.resolve('@excalidraw/excalidraw/package.json')),
    }
  } catch {
    // Fallback for development environments
    const fallbackLibsDir = path.join(__dirname, 'node_modules')
    return {
      react: path.join(fallbackLibsDir, 'react'),
      reactDom: path.join(fallbackLibsDir, 'react-dom'),
      excalidraw: path.join(fallbackLibsDir, '@excalidraw', 'excalidraw'),
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

With the paths resolved, the server can expose React and Excalidraw bundles directly:

const vendorFiles = {
  '/vendor/react.production.min.js': path.join(paths.react, 'umd/react.production.min.js'),
  '/vendor/react-dom.production.min.js': path.join(paths.reactDom, 'umd/react-dom.production.min.js'),
  '/vendor/excalidraw.production.min.js': path.join(paths.excalidraw, 'dist/excalidraw.production.min.js'),
}
Enter fullscreen mode Exit fullscreen mode

This approach locks us to a specific Excalidraw version (v0.17.6), since newer versions no longer provide UMD bundles — but it allows for a single-file, zero-build setup.


2. HTML Template & Font Injection

Font customization happens entirely in the HTML template. By preloading a custom font and overriding the default Virgil font via @font-face, Excalidraw seamlessly uses the custom typeface:

<link rel="preload" href="/fonts/Excalifont-Regular.woff2" as="font" type="font/woff2" crossorigin>

<style>
  @font-face {
    font-family: 'Virgil';
    src: url('/fonts/Excalifont-Regular.woff2') format('woff2');
    font-weight: normal;
    font-style: normal;
    font-display: block;
  }

  .excalidraw .layer-ui__wrapper,
  .excalidraw svg text {
    font-family: 'Excalifont', 'Virgil', cursive !important;
  }

  html, body { background: #000; height: 100%; margin: 0; }
  #root { height: 100vh; }
</style>
Enter fullscreen mode Exit fullscreen mode

This technique is inspired by Dawid’s browser extension method — but fully baked into the server, requiring no client-side tinkering.


3. Font Override Technique Explained

The override works through three simple steps:

  1. Preload the custom font for faster rendering
  2. Override the Virgil font using @font-face
  3. Force font usage with !important on Excalidraw’s CSS selectors

This ensures the entire UI and drawn text consistently use the custom font.


4. Cross-Platform Instance Management

Managing multiple named instances required respecting platform-specific conventions for storing state. Here’s how the server determines the appropriate state directory:

const getStateDir = () => {
  const platform = os.platform()
  let stateDir

  if (platform === 'win32') {
    stateDir = path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'excalocal')
  } else if (platform === 'darwin') {
    stateDir = path.join(os.homedir(), 'Library', 'Application Support', 'excalocal')
  } else {
    const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state')
    stateDir = path.join(stateHome, 'excalocal')
  }

  if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true })
  return stateDir
}
Enter fullscreen mode Exit fullscreen mode

Platform conventions:

  • 🐧 Linux: ~/.local/state/excalocal (XDG spec)
  • 🍎 macOS: ~/Library/Application Support/excalocal
  • 🪟 Windows: %APPDATA%\excalocal

5. Dynamic Port Allocation

When multiple instances are launched, the server automatically scans for available ports:

server.on('error', (err) => {
  if (err.code === 'EADDRINUSE') {
    currentPort++
    console.log(`Port ${PORT} is in use, trying ${currentPort}...`)
    server.listen(currentPort, HOST)
  } else {
    console.error('Server error:', err.message)
    process.exit(1)
  }
})
Enter fullscreen mode Exit fullscreen mode

This avoids conflicts and makes it trivial to run several named Excalidraw sessions simultaneously.


6. Security Considerations

Since the server serves user-generated content, it includes a simple path sanitization step to prevent directory traversal attacks:

const sanitizePath = (inputPath) => {
  const normalized = path.normalize(inputPath).replace(/^(\..[/\\])+/, '')
  if (normalized.includes('\0') || normalized.includes('..')) {
    throw new Error('Invalid file path detected')
  }
  return normalized
}
Enter fullscreen mode Exit fullscreen mode

🚀 The Result

The final product is a single executable that:

  • Installs via: npm install -g excalocal
  • Launches with: excalocal
  • Supports named instances: excalocal -n work -b
  • Lists active instances: excalocal -l
  • Works entirely offline
  • Uses beautiful custom fonts by default

📌 Key Takeaways

  1. UMD bundles enable serving React apps without complex build steps.
  2. CSS @font-face overrides can seamlessly inject custom fonts into third-party apps.
  3. require.resolve() is invaluable for dynamically locating npm dependencies.
  4. Cross-platform state directories require careful handling for a native feel.
  5. Single-file Node.js servers can be surprisingly powerful when architected well.

You can find the complete source code on GitHub and install it from npm.


Special thanks to @DawidWraga for pioneering the original font injection approach that inspired this project.

Top comments (0)