Skip to main content
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.
1

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 })
})
2

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

1

Install jsdom

npm install jsdom
2

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
}
3

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

1

Install happy-dom

npm install happy-dom
2

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
}
3

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}`)

Performance Considerations

When using DOM simulation libraries:
  1. Cache editor instances: Reuse editor instances when possible
  2. Batch operations: Process multiple documents in batches
  3. Use happy-dom for better performance: It’s generally faster than jsdom
  4. Limit document size: Very large documents may be slow to process
  5. 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:
  1. Verify you’re providing a document to getDocHTML({ document })
  2. Ensure jsdom or happy-dom is properly initialized
  3. Check that you’re not calling DOM-dependent APIs without a DOM

Performance issues

If SSR is slow:
  1. Use happy-dom instead of jsdom (it’s faster)
  2. Cache converted HTML on the server
  3. Use browser pre-rendering instead of server conversion
  4. Consider using edge caching (CDN)

Memory leaks

If you’re experiencing memory issues:
  1. Properly destroy DOM instances after use
  2. Don’t create new editor instances for each request
  3. Implement proper cleanup in long-running processes
  4. Monitor memory usage and adjust batch sizes

Next Steps