Skip to content

Server-Side Rendering (SSR) ​

Server-Side Rendering (SSR) is a powerful technique for improving performance, SEO, and user experience in web applications. It enables pre-rendering of React components on the server, delivering a fully-formed HTML response to the clientβ€”before any JavaScript is executed in the browser.

Traditionally, SSR relies on Node.js and frameworks like Next.js, but what if we want an isolated, lightweight, and secure SSR environment?

Enter QuickJS. πŸš€

With QuickJS WebAssembly, we can execute JavaScript on the server in a sandboxed environment without the overhead of a full Node.js process. This allows us to:

βœ… Render React components dynamically on the backend.
βœ… Keep execution isolated and secure.
βœ… Load dependencies dynamically from sources like esm.sh.
βœ… Run SSR in environments where Node.js isn't available.

Let’s explore how to set up Server-Side Rendering with QuickJS.

πŸ—οΈ Setting Up QuickJS for SSR ​

To render React components on the server using QuickJS, we need to:

1️⃣ Load React and ReactDOMServer dynamically.
2️⃣ Execute the rendering process inside a sandboxed environment.
3️⃣ Retrieve the final HTML output and return it to the client.

πŸ”Ή Step 1: Configuring the Sandbox ​

We need to configure module resolution so that QuickJS can import React and ReactDOMServer dynamically. Since we are not running a full Node.js environment, we will fetch dependencies from esm.sh, a CDN for JavaScript modules.

Here’s how we normalize module paths to support:

  • Relative imports
  • Absolute imports from esm.sh
  • Node.js module replacements
ts
const modulePathNormalizer = async (baseName: string, requestedName: string) => {
  // Import directly from esm.sh
  if (requestedName.startsWith('esm.sh') || requestedName.startsWith('https://esm.sh')) {
    return requestedName.startsWith('https://') ? requestedName : `https://${requestedName}`
  }

  // Resolve relative imports from esm.sh
  if (requestedName.startsWith('/')) {
    return `https://esm.sh${requestedName}`
  }

  // Handle relative imports in local files
  if (requestedName.startsWith('.')) {
    if (baseName.startsWith('https://esm.sh')) {
      return new URL(requestedName, baseName).toString()
    }
    return resolve(`/${baseName.split('/').slice(0, -1).join('/')}`, requestedName)
  }

  // Normalize Node.js module imports
  const moduleName = requestedName.replace('node:', '')
  return join('/node_modules', moduleName)
}

πŸ”Ή Step 2: Custom Module Loader ​

Since QuickJS does not provide native module resolution, we must fetch JavaScript modules on-demand using a custom module loader.

This loader intercepts module imports and:
βœ… Loads modules from esm.sh.
βœ… Falls back to the default QuickJS loader for local modules.
βœ… Handles errors if a module fails to load.

ts
const getModuleLoader = (fs, runtimeOptions) => {
  const defaultLoader = getAsyncModuleLoader(fs, runtimeOptions)

  const loader = async (moduleName: string, context: QuickJSAsyncContext) => {
    console.log('Fetching module:', moduleName)

    if (!moduleName.startsWith('https://esm.sh')) {
      return defaultLoader(moduleName, context)
    }

    const response = await fetch(moduleName)
    if (!response.ok) {
      throw new Error(`Failed to load module ${moduleName}`)
    }

    return await response.text()
  }

  return loader
}

πŸ”Ή Step 3: Running the SSR Code ​

With our sandbox configured, we can now run server-side React rendering inside QuickJS.

πŸ“Œ React Code to Render (Running Inside the Sandbox) ​

ts
const code = `
import * as React from 'esm.sh/react@15'
import * as ReactDOMServer from 'esm.sh/react-dom@15/server'

const e = React.createElement
export default ReactDOMServer.renderToStaticMarkup(
  e('div', null, e('strong', null, 'Hello world!'))
)
`

πŸƒ Executing the Code in QuickJS ​

ts
import { type SandboxAsyncOptions, loadAsyncQuickJs } from '@sebastianwessel/quickjs'

const { runSandboxed } = await loadAsyncQuickJs()

const options: SandboxAsyncOptions = {
  modulePathNormalizer,
  getModuleLoader,
}

const result = await runSandboxed(async ({ evalCode }) => evalCode(code), options)

console.log(result)
// Output: "<div><strong>Hello world!</strong></div>"

πŸ”₯ Success! Our server-side React component is rendered as static HTML.

🏁 Putting It All Together ​

πŸ“Œ Full SSR Workflow with QuickJS ​

ts
import { join, resolve } from 'node:path'
import type { QuickJSAsyncContext } from 'quickjs-emscripten-core'
import { type SandboxAsyncOptions, getAsyncModuleLoader, loadAsyncQuickJs } from '../../src/index.js'

const { runSandboxed } = await loadAsyncQuickJs()

const modulePathNormalizer = async (baseName: string, requestedName: string) => {
	// import from esm.sh
	if (requestedName.startsWith('esm.sh')) {
		return `https://${requestedName}`
	}

	// import from esm.sh
	if (requestedName.startsWith('https://esm.sh')) {
		return requestedName
	}

	// import within an esm.sh import
	if (requestedName.startsWith('/')) {
		return `https://esm.sh${requestedName}`
	}

	// relative import
	if (requestedName.startsWith('.')) {
		// relative import from esm.sh loaded module
		if (baseName.startsWith('https://esm.sh')) {
			return new URL(requestedName, baseName).toString()
		}

		// relative import from local import
		const parts = baseName.split('/')
		parts.pop()

		return resolve(`/${parts.join('/')}`, requestedName)
	}

	// unify module import name
	const moduleName = requestedName.replace('node:', '')

	return join('/node_modules', moduleName)
}

const getModuleLoader = (fs, runtimeOptions) => {
	const defaultLoader = getAsyncModuleLoader(fs, runtimeOptions)

	const loader = async (moduleName: string, context: QuickJSAsyncContext) => {
		console.log('fetching module', moduleName)

		if (!moduleName.startsWith('https://esm.sh')) {
			return defaultLoader(moduleName, context)
		}

		const response = await fetch(moduleName)
		if (!response.ok) {
			throw new Error(`Failed to load module ${moduleName}`)
		}
		const content = await response.text()
		return content
	}

	return loader
}

const options: SandboxAsyncOptions = {
	modulePathNormalizer,
	getModuleLoader,
}

const code = `
import * as React from 'esm.sh/react@15'
import * as ReactDOMServer from 'esm.sh/react-dom@15/server'
const e = React.createElement
export default ReactDOMServer.renderToStaticMarkup(
  e('div', null, e('strong', null, 'Hello world!'))
)
`

const result = await runSandboxed(async ({ evalCode }) => evalCode(code), options)

console.log(result)

🎯 Key Takeaways ​

βœ… QuickJS enables fast and secure SSR without Node.js.
βœ… Modules can be dynamically loaded from esm.sh.
βœ… Custom module loaders allow greater flexibility.
βœ… QuickJS's sandboxing ensures safe execution.

πŸ”— Next Steps ​

πŸš€ Try it outβ€”experiment with different React components.
πŸ› οΈ Optimize module fetchingβ€”cache modules for faster execution.
πŸ” Expand functionalityβ€”add support for props and dynamic content.

QuickJS makes Server-Side Rendering lightweight, efficient, and highly secureβ€”without requiring a full Node.js environment. πŸš€

This article keeps things structured, engaging, and easy to follow, making SSR with QuickJS clear and approachable. Let me know if you'd like any refinements!