# Install dependencies
npm install ssr-electron
// main.js
import { app, BrowserWindow } from 'electron';
import { createSSR } from 'ssr-electron';
// Create the SSR bridge instance - it automatically registers schemes and handlers
const ssr = createSSR({ debug: true });
function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
// Load the app from the virtual URL
win.loadURL('http://localhost/');
return win;
}
app.whenReady().then(() => {
// Register route for the main page
ssr.registerRoute('/', (request, url) => {
// HTML is generated and served directly from the main process
return new Response(`
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Electron SSR Example</title>
<script src="https://unpkg.com/[email protected]"></script>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
button {
background-color: #4a86e8;
color: white;
border: none;
border-radius: 4px;
padding: 10px 15px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>Electron SSR Example</h1>
<button
hx-get="/system-info"
hx-target="#content">
Load System Info
</button>
<div id="content">
<p>Click the button to load data from the main process</p>
</div>
</body>
</html>
`, {
headers: { 'Content-Type': 'text/html' }
});
});
// Register route for system info
ssr.registerRoute('/system-info', (request, url) => {
// This data is only available in the main process
return new Response(`
<div>
<h2>System Information</h2>
<ul>
<li>Platform: ${process.platform}</li>
<li>Architecture: ${process.arch}</li>
<li>Node.js Version: ${process.version}</li>
<li>Electron Version: ${process.versions.electron}</li>
</ul>
<p><em>This data comes directly from the Electron main process!</em></p>
</div>
`, {
headers: { 'Content-Type': 'text/html' }
});
});
createWindow();
});
If you've built an Electron app, you're familiar with the pain of IPC (Inter-Process Communication). The main and renderer processes are completely isolated, forcing developers to set up complex messaging systems just to share data and trigger actions across the boundary. This results in:
- Verbose boilerplate code for sending and receiving messages
- Complex state synchronization between processes
- Error-prone message handling
- Type safety challenges across the boundary
Several projects make working with IPC slightly more fun:
- electron-trpc - Uses tRPC to create type-safe APIs across the IPC boundary
- zubridge - Brings Zustand state management across the IPC boundary
- I even played around with re-building the above with Effect.ts which was quite nice.
I wanted something simpler.
I stumbled upon this article in my rabbit hole: The ultimate Electron app with Next.js and React Server Components. I ended up not going this direction as I deeply dislike NextJS, but later I stumbled upon Datastar and HTMX and remembered this article and I wondered if I could adapt the idea to work them instead. Potentially I could also get this working with just bare React π€
The result is Electron SSR - a library that lets you use server-side rendering patterns directly in your Electron app without dealing with IPC.
The biggest advantage of Electron SSR is that it allows you to return views from the main process that have direct access to Node.js modules that are normally unavailable in the renderer.
With traditional Electron development, if you want to use Node.js modules in your renderer or access data from the main process, you need to:
- Create a preload script to safely expose main process functionality
- Set up contextBridge to define the API that will be available in the renderer
- Create IPC channels for each type of communication needed
- Set up handlers in the main process for each channel
- Call these exposed IPC methods from the renderer
- Parse and handle the responses
- Update your UI accordingly
This requires careful coordination between multiple files and introduces complexity:
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
readNote: () => ipcRenderer.invoke('read-note'),
saveNote: (content) => ipcRenderer.invoke('save-note', content),
// Expose a function to listen for notifications from main
onNoteUpdated: (callback) => ipcRenderer.on('note-updated', (_event, value) => callback(value))
})
// main.js
const { app, BrowserWindow, ipcMain, safeStorage } = require('electron')
const fs = require('fs/promises')
// Creating the window
let mainWindow;
function createWindow() {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true
}
});
mainWindow.loadFile('index.html');
}
// IPC handlers
ipcMain.handle('read-note', async () => {
try {
const encryptedContent = await fs.readFile('user-notes.enc')
return await safeStorage.decryptString(encryptedContent)
} catch (error) {
return { error: error.message }
}
})
ipcMain.handle('save-note', async (event, content) => {
try {
const encrypted = await safeStorage.encryptString(content)
await fs.writeFile('user-notes.enc', encrypted)
// After saving, notify the renderer about the update
mainWindow.webContents.send('note-updated', 'Note saved successfully!')
return { success: true }
} catch (error) {
return { error: error.message }
}
})
// renderer.js
document.getElementById('load-button').addEventListener('click', async () => {
const content = await window.electronAPI.readNote()
if (content.error) {
document.getElementById('note-container').innerText = `Error: ${content.error}`
} else {
document.getElementById('note-container').innerText = content
}
})
document.getElementById('save-button').addEventListener('click', async () => {
const content = document.getElementById('note-input').value
const result = await window.electronAPI.saveNote(content)
if (result.error) {
alert(`Failed to save: ${result.error}`)
} else {
alert('Saved successfully!')
}
})
// Listen for updates from the main process
window.electronAPI.onNoteUpdated((message) => {
document.getElementById('status-container').innerText = message
// You could also refresh the note content here
})
With Electron SSR, you can simply define a route handler that:
- Directly accesses Node.js modules and Electron APIs
- Returns HTML with the results already integrated
- The renderer just makes a request and gets the rendered result
// In your main process setup
import { createSSR } from 'ssr-electron';
import fs from 'fs/promises';
import { safeStorage } from 'electron';
const ssr = createSSR({ debug: true });
// Register a route that reads a file and returns its content
ssr.registerRoute('/notes', async (request, url) => {
try {
// Access Node.js modules directly
const encryptedContent = await fs.readFile('user-notes.enc');
const content = await safeStorage.decryptString(encryptedContent);
// Return HTML with the content already integrated
return new Response(`
<div id="notes-container">
<h1>Your Notes</h1>
<div class="note-content">${content}</div>
<button hx-post="/save-note" hx-target="#notes-container">Save</button>
</div>
`, {
headers: { 'Content-Type': 'text/html' }
});
} catch (error) {
return new Response(`<div>Error: ${error.message}</div>`, { status: 500 });
}
});
// Handle saving notes
ssr.registerRoute('/save-note', async (request, url) => {
// Get form data from the request
const formData = await request.formData();
const noteContent = formData.get('content');
// Encrypt and save to filesystem
const encrypted = await safeStorage.encryptString(noteContent);
await fs.writeFile('user-notes.enc', encrypted);
// Return updated UI
return new Response(`
<div id="notes-container">
<h1>Your Notes</h1>
<div class="note-content">${noteContent}</div>
<button hx-post="/save-note" hx-target="#notes-container">Save</button>
<div class="success-message">Saved successfully!</div>
</div>
`, {
headers: { 'Content-Type': 'text/html' }
});
}, 'POST');
In your renderer, the code is pure HTML and HTMX - no IPC needed:
<body>
<div id="app">
<div hx-get="/notes" hx-trigger="load">Loading...</div>
</div>
<script src="https://unpkg.com/[email protected]"></script>
</body>
Electron SSR also supports Server-Sent Events, making it easy to push updates from the main process to the renderer:
// In main process
import { watch } from 'fs';
// Set up file watcher
watch('user-notes.enc', () => {
// When file changes, broadcast to all clients
ssr.broadcastContent('note-updated', `
<div class="note-content">${decryptedContent}</div>
`);
});
// In renderer
<div hx-sse="connect:sse://events">
<div hx-sse="swap:note-updated" id="note-content">
Initial content
</div>
</div>
This approach completely eliminates the need to manually handle IPC communication, making your Electron apps feel more like traditional web development while still leveraging the full power of Node.js and native modules.
Electron SSR works by
- Register HTTP routes and handle requests directly in Electron
- Creating a virtual "server" that runs in the main process
- Handling HTTP-like requests from the renderer
- Supporting Server-Sent Events (SSE) for real-time updates
- Integrating seamlessly with HTMX and Datastar
Essentially, it turns your main process into a server that your renderer can communicate with using standard web protocols, without actually running a server or opening any ports.
See the examples
directory for complete examples:
simple-example.js
- Basic example with HTMX and Alpine.jssimple-alpine.js
- Basic example, but using only AlpineJShtmx-notes.js
- Example with realtime synced secure notesdatastar.js
- Example using Datastarrealtime.js
- Testing streaming HTML updates over SSE at 60+ FPS with Datastar
I just started playing around with HTMX and Datastar, so feel free to submit updates or more examples to show off the power!
MIT