ProseKit can run in Node.js environments for background processing, CLI applications, and Server-Side Rendering (SSR). This guide will show you how to use ProseKit on the server and handle the limitations of non-browser environments.
Overview
The primary limitation of running ProseKit in Node.js is that DOM-dependent APIs are unavailable unless you provide a DOM implementation. However, you can still:
- Create and manipulate editor instances
- Execute commands programmatically
- Work with JSON content
- Process documents in the background
- Generate content for SSR
Creating an Editor in Node.js
You can create an editor and execute commands just like in a browser environment:
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor } from 'prosekit/core'
const extension = defineBasicExtension()
const editor = createEditor({ extension })
// Set content with JSON
editor.setContent({
type: 'doc',
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Hello, Node.js!' }] },
],
})
// Run commands
editor.commands.insertImage({
src: 'https://example.com/logo.png',
width: 120,
height: 60,
})
// Get the document as JSON
const json = editor.getDocJSON()
console.log(json)
DOM-Dependent APIs
In a standard Node.js environment there is no DOM, so methods requiring DOM access will throw errors:
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor } from 'prosekit/core'
const extension = defineBasicExtension()
const editor = createEditor({ extension })
// ❌ Mounting fails without DOM
editor.mount(document.createElement('div'))
// ❌ HTML serialization requires DOM APIs
editor.getDocHTML()
// ❌ HTML parsing requires DOM APIs
editor.setContent('<p>HTML input</p>')
SSR Strategies
There are two main strategies for handling SSR with ProseKit:
Strategy 1: Browser Pre-rendering
The simplest approach for server-side rendering is client-side pre-rendering: allow the browser to convert documents to HTML and store the result.
Generate HTML in the browser
// Browser code
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor } from 'prosekit/core'
const extension = defineBasicExtension()
const editor = createEditor({ extension })
// Generate HTML from the editor
const html = editor.getDocHTML()
// Send to server for storage
await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html })
})
Serve pre-rendered HTML
// Server code (Express example)
app.get('/document/:id', async (req, res) => {
const document = await db.documents.findById(req.params.id)
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>${document.title}</title>
<link rel="stylesheet" href="/prosekit.css">
</head>
<body>
<div class="document-content">
${document.html}
</div>
</body>
</html>
`)
})
This approach is simple and reliable, but requires the client to generate HTML.
Strategy 2: Server-Side DOM Simulation
For JSON-to-HTML conversion within Node.js, use a headless DOM library such as jsdom or happy-dom.
Headless DOM implementations are sufficient for most use cases but may not perfectly replicate browser rendering behavior.
Using jsdom
Create a conversion function
import { JSDOM } from 'jsdom'
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, type NodeJSON } from 'prosekit/core'
export function convertJSONToHTML(json: NodeJSON): string {
// Initialize virtual DOM
const dom = new JSDOM('')
const document = dom.window.document
// Create editor
const extension = defineBasicExtension()
const editor = createEditor({ extension })
// Set content
editor.setContent(json)
// Convert to HTML
const html = editor.getDocHTML({ document })
return html
}
Use in your server
import express from 'express'
import { convertJSONToHTML } from './convert'
const app = express()
app.get('/document/:id', async (req, res) => {
const document = await db.documents.findById(req.params.id)
// Convert JSON to HTML on the server
const html = convertJSONToHTML(document.content)
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>${document.title}</title>
<link rel="stylesheet" href="/prosekit.css">
</head>
<body>
<div class="document-content">
${html}
</div>
</body>
</html>
`)
})
app.listen(3000)
Using happy-dom
Create a conversion function
import { Window } from 'happy-dom'
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, type NodeJSON } from 'prosekit/core'
export function convertJSONToHTML(json: NodeJSON): string {
// Initialize virtual DOM
const window = new Window()
const document = window.document
// Create editor
const extension = defineBasicExtension()
const editor = createEditor({ extension })
// Set content
editor.setContent(json)
// Convert to HTML
const html = editor.getDocHTML({ document })
return html
}
Use in your server
Same as the jsdom example above.
Framework-Specific SSR
Next.js
For Next.js, you can use the browser pre-rendering strategy:
// app/document/[id]/page.tsx
import { db } from '@/lib/db'
export async function generateMetadata({ params }) {
const document = await db.documents.findById(params.id)
return { title: document.title }
}
export default async function DocumentPage({ params }) {
const document = await db.documents.findById(params.id)
return (
<div className="container">
<h1>{document.title}</h1>
<div
className="document-content"
dangerouslySetInnerHTML={{ __html: document.html }}
/>
</div>
)
}
SvelteKit
// src/routes/document/[id]/+page.server.ts
import { db } from '$lib/db'
export async function load({ params }) {
const document = await db.documents.findById(params.id)
return {
document: {
title: document.title,
html: document.html,
},
}
}
<!-- src/routes/document/[id]/+page.svelte -->
<script lang="ts">
export let data
</script>
<div class="container">
<h1>{data.document.title}</h1>
<div class="document-content">
{@html data.document.html}
</div>
</div>
Nuxt
// pages/document/[id].vue
<script setup>
const route = useRoute()
const { data: document } = await useFetch(`/api/documents/${route.params.id}`)
</script>
<template>
<div class="container">
<h1>{{ document.title }}</h1>
<div class="document-content" v-html="document.html" />
</div>
</template>
Background Processing
Use ProseKit in background jobs for document processing:
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, type NodeJSON } from 'prosekit/core'
import { JSDOM } from 'jsdom'
interface ProcessingJob {
documentId: string
content: NodeJSON
}
export async function processDocument(job: ProcessingJob) {
const dom = new JSDOM('')
const document = dom.window.document
const extension = defineBasicExtension()
const editor = createEditor({ extension })
// Set content
editor.setContent(job.content)
// Process the document
const wordCount = editor.state.doc.textContent.split(/\s+/).length
const html = editor.getDocHTML({ document })
// Extract headings for table of contents
const headings = []
editor.state.doc.descendants((node) => {
if (node.type.name === 'heading') {
headings.push({
level: node.attrs.level,
text: node.textContent,
})
}
})
// Save processed data
await db.documents.update(job.documentId, {
html,
wordCount,
headings,
processedAt: new Date(),
})
}
CLI Applications
Create CLI tools that work with ProseKit documents:
#!/usr/bin/env node
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor } from 'prosekit/core'
import { readFileSync, writeFileSync } from 'fs'
import { JSDOM } from 'jsdom'
const [,, inputFile, outputFile] = process.argv
if (!inputFile || !outputFile) {
console.error('Usage: prosemirror-convert <input.json> <output.html>')
process.exit(1)
}
// Read JSON file
const json = JSON.parse(readFileSync(inputFile, 'utf-8'))
// Convert to HTML
const dom = new JSDOM('')
const document = dom.window.document
const extension = defineBasicExtension()
const editor = createEditor({ extension })
editor.setContent(json)
const html = editor.getDocHTML({ document })
// Write HTML file
writeFileSync(outputFile, html)
console.log(`Converted ${inputFile} to ${outputFile}`)
When using DOM simulation libraries:
- Cache editor instances: Reuse editor instances when possible
- Batch operations: Process multiple documents in batches
- Use happy-dom for better performance: It’s generally faster than jsdom
- Limit document size: Very large documents may be slow to process
- Consider memory usage: Clean up DOM instances when done
import { Window } from 'happy-dom'
import { defineBasicExtension } from 'prosekit/basic'
import { createEditor, type NodeJSON } from 'prosekit/core'
class DocumentConverter {
private window: Window
private editor
constructor() {
this.window = new Window()
const extension = defineBasicExtension()
this.editor = createEditor({ extension })
}
convert(json: NodeJSON): string {
this.editor.setContent(json)
return this.editor.getDocHTML({ document: this.window.document })
}
destroy() {
// Clean up
this.window.close()
}
}
// Use the converter
const converter = new DocumentConverter()
const html1 = converter.convert(doc1)
const html2 = converter.convert(doc2)
converter.destroy()
Troubleshooting
”document is not defined” error
If you see this error:
- Verify you’re providing a document to
getDocHTML({ document })
- Ensure jsdom or happy-dom is properly initialized
- Check that you’re not calling DOM-dependent APIs without a DOM
If SSR is slow:
- Use happy-dom instead of jsdom (it’s faster)
- Cache converted HTML on the server
- Use browser pre-rendering instead of server conversion
- Consider using edge caching (CDN)
Memory leaks
If you’re experiencing memory issues:
- Properly destroy DOM instances after use
- Don’t create new editor instances for each request
- Implement proper cleanup in long-running processes
- Monitor memory usage and adjust batch sizes
Next Steps