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:
- HTTP Server – Serves static assets and the React app
- Font Override System – Injects custom CSS to replace Excalidraw’s default fonts
- 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'),
}
}
}
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'),
}
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>
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:
- Preload the custom font for faster rendering
-
Override the
Virgil
font using@font-face
-
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
}
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)
}
})
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
}
🚀 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
- UMD bundles enable serving React apps without complex build steps.
-
CSS
@font-face
overrides can seamlessly inject custom fonts into third-party apps. -
require.resolve()
is invaluable for dynamically locating npm dependencies. - Cross-platform state directories require careful handling for a native feel.
- 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)