npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@srfnstack/fntags-ssr

v1.0.1

Published

Server-side rendering and hydration for fntags

Readme

@srfnstack/fntags-ssr

Server-side rendering and client-side hydration for fntags.

Render your fntags components to HTML on the server for fast first paint, then hydrate on the client to attach event listeners and resume interactivity. No changes to the core framework required — SSR works through the existing public API.

How It Works

fntags components are plain functions that create real DOM elements. On the server, renderToString uses happy-dom to provide a DOM implementation, runs your component tree, serializes the result to HTML, and captures a snapshot of any registeredState values. On the client, hydrate restores the state snapshot and re-executes the component tree to attach event listeners and reactive bindings.

Install

npm install @srfnstack/fntags-ssr

happy-dom is included as a dependency (server-side only — it is not bundled into client code).

Server API

renderToString(options)

Renders a fntags application to an HTML string. Waits for all async children (promises) to resolve before returning.

import { renderToString } from '@srfnstack/fntags-ssr'

const { html, state } = await renderToString({
  url: '/about',
  appFn: async () => {
    const { App } = await import('./app/App.mjs')
    return App()
  }
})

Important: Use a dynamic import() inside appFn rather than a static import at the top of the file. fnroute accesses window at module level when it initializes pathState and registers a popstate listener. renderToString sets up a DOM environment (via happy-dom) before calling appFn, so a dynamic import ensures fntags loads after window exists on the server.

Options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | url | string | required | URL path to render (e.g. '/about') | | appFn | () => Node | required | Function that returns the app's root element | | rootPath | string | '/' | Root path prefix for routing | | timeout | number | 5000 | Max ms to wait for async children | | origin | string | 'http://localhost' | Origin for the full URL |

Returns: Promise<{ html: string, state: Record<string, any> }>

  • html — the rendered HTML string (the inner content, not a full document)
  • state — a snapshot of all registeredState values, ready to embed in the page

renderToStream(options)

Streaming variant. Flushes synchronous HTML immediately and streams state as a final <script> chunk. Takes the same options as renderToString.

import { renderToStream } from '@srfnstack/fntags-ssr'

const stream = renderToStream({
  url: '/',
  appFn: async () => {
    const { App } = await import('./app/App.mjs')
    return App()
  }
})

Returns: ReadableStream<string>

Client API

hydrate(container, appFn)

Restores server-rendered state and re-executes the component tree to attach event listeners and reactive bindings.

import { hydrate } from '@srfnstack/fntags-ssr/hydrate'
import { App } from './app/App.mjs'

hydrate(document.getElementById('app'), () => App())

Parameters:

| Parameter | Type | Description | |-----------|------|-------------| | container | HTMLElement | The element containing server-rendered HTML | | appFn | () => Node | Same component factory used on the server |

Browser Module Resolution

The client script uses bare specifiers like @srfnstack/fntags and @srfnstack/fntags-ssr/hydrate. Browsers don't natively resolve these — you need either a bundler or an import map. For no-build setups, add an import map to your HTML before any <script type="module"> tags:

<script type="importmap">
{
  "imports": {
    "@srfnstack/fntags": "/path/to/fntags/index.js",
    "@srfnstack/fntags-ssr/hydrate": "/path/to/fntags-ssr/src/hydrate.mjs"
  }
}
</script>

The import map applies to all modules on the page, so internal imports within fntags-ssr (like hydrate.mjs importing from @srfnstack/fntags) are also resolved through it. Relative imports within the fntags source (./fntags.mjs, ./fnroute.mjs, etc.) resolve naturally as long as the directory structure is preserved when serving.

Your server needs to serve the fntags and fntags-ssr source files at the URLs specified in the import map. The files to serve are:

  • @srfnstack/fntags/index.js
  • @srfnstack/fntags/src/fntags.mjs
  • @srfnstack/fntags/src/fnroute.mjs
  • @srfnstack/fntags/src/fnelements.mjs
  • @srfnstack/fntags/src/svgelements.mjs
  • @srfnstack/fntags-ssr/src/hydrate.mjs

How you serve these depends on your server. Any static file server or middleware that can serve files from node_modules with the correct directory structure will work.

State Serialization

SSR automatically serializes all states created with registeredState(id, value) — the same API used for HMR state preservation. Use registeredState instead of fnstate for any state that should survive the server-to-client handoff:

import { registeredState } from '@srfnstack/fntags'

// This state will be serialized by the server and restored on the client
const count = registeredState('count', 0)

On the server, renderToString captures a snapshot of all registered states. On the client, hydrate reads the snapshot from window.__FNTAGS_SSR_STATE__ and populates the registry before your app factory runs.

Important: When embedding the state object in a <script> tag in your HTML template, use escapeScriptContent to prevent XSS from state values containing </script>:

import { renderToString, escapeScriptContent } from '@srfnstack/fntags-ssr'

const { html, state } = await renderToString({
  url: '/',
  appFn: async () => {
    const { App } = await import('./app/App.mjs')
    return App()
  }
})

const page = `
<div id="app">${html}</div>
<script>window.__FNTAGS_SSR_STATE__=${escapeScriptContent(JSON.stringify(state))}</script>
<script type="module" src="/client.mjs"></script>
`

States created with plain fnstate() are not serialized — they'll initialize with their default values on the client.

Routing

SSR works with fnroute out of the box. Pass the target URL to renderToString and it sets pathState before rendering, so route(), routeSwitch(), and pathParameters all work as expected:

const { html } = await renderToString({
  url: '/user/42',
  appFn: async () => {
    const { App } = await import('./app/App.mjs')
    return App()
  }
})
// Renders the /user/:id route with pathParameters().id === '42'

Examples

Complete working examples are included:

Both demonstrate server-side rendering, state serialization, client hydration, routing, and import maps.

cd example
npm install
npm start

Concurrency

renderToString uses a mutex internally because happy-dom globals are installed on globalThis. Concurrent calls are serialized automatically. For high-throughput scenarios, consider running renders in Node.js worker threads.

License

MIT