The Code

The code level architecture for this is very similar to most SSR models and Vite has a good explanation for how to write your own ssr with vite

→ Vite Guides - Server-Side Rendering

We used webpack instead to be able to make it a little more verbose so it's easier to explain what's going on.

server/app.js

Starting with server/app.js file. If you have the codebase open locally it would be helpful while reading this.

The codesnippet only highlights the needed areas

import preactRenderToString from 'preact-render-to-string'
import HomePage from '../pages/HomePage.js'
import { h } from 'preact'
import { withManifestBundles } from '../lib/html.js'

const app = express()

app.get('/', async (req, res) => {
  res.send(
    withManifestBundles({
      body: preactRenderToString(h(HomePage, {})),
    })
  )
})

Looking at the imports, we have the same imports as mentioned in the Getting Started section and not much has changed.

The only addition here is the withManifestBundles helper which is what we'll talk about next.

lib/html.js

The HTML helper is different in different variants of the template but we'll be only going through webpack version which is on the main branch.

The base usecase of the helper is to be able to go through a manifest json that lists what files are being bundled by webpack and their hashed paths when being used in production.

This is required since we will not know the hash and we need a programmatic way to find it out.

This manifest is generated by webpack's client configuration which we'll take a look at in a minute.

// fetch the manifest from the client output
import manifest from '../../dist/js/manifest.json'

export const withManifestBundles = ({ styles, body }) => {
  // go through each key in the manifest and construct
  // a script tag for each.
  const bundledScripts = Object.keys(manifest).map(key => {
    const scriptPath = `/public/js/${manifest[key]}`
    return `<script src=${scriptPath}></script>`
  })

  return `<html lang="en">
    <head>
      <meta charset="UTF-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <style id="_goober">
        ${styles}
      </style>
    </head>

    <body>
      ${body}
    </body>
    ${bundledScripts.join('')}
  </html>`
}

As explained in the comments, we just grab all the files we need from the manifest and inject them as script tags into the final HTML that is sent from the server.

Moving onto the configuration that makes it possible to build this.

webpack.config.*.js

I tried to keep the webpack configuration as minimal as possible to avoid scaring people away from the whole idea so let's go through the configuration.

// webpack.config.server.js
const path = require('path')
const nodeExternals = require('webpack-node-externals')

module.exports = {
  mode: process.env.NODE_ENV != 'production' ? 'development' : 'production',
  target: 'node',
  entry: path.resolve(__dirname, './src/server/app.js'),
  output: {
    filename: 'server.js',
    path: path.resolve(__dirname, './dist'),
  },
  stats: 'errors-warnings',
  resolve: {
    extensions: ['.js', '.jsx'],
  },
  module: {
    rules: [{ test: /\.jsx?$/, loader: 'babel-loader' }],
  },
  externals: [nodeExternals()],
}

Most of them need no explanation, and the only loader we have in place is the babel-loader since we are using a CSS-IN-JS solution for styling.

There's nothing magical going on here, we just give it the entry point of server/app.js and let it build itself to the same folder as the client's output.

moving on to the client side config, which does add a few more things than simply providing an entry and getting an output.

This is shortened out to explain the relavant bits

// webpack.config.client.js

const entryPoints = glob
  .sync(path.resolve(__dirname, './src/client') + '/**/*.js', {
    absolute: true,
  })
  .reduce((acc, path) => {
    const entry = path.match(/[^\/]+\.jsx?$/gm)[0].replace(/.jsx?$/, '')
    acc[entry] = path
    return acc
  }, {})

So the first section is basically finding all files in src/client and creating an object of entries for webpack.

Example: if src/client/app.client.js is a file then the output of the above would be

{
  "app.client": "./src/client/app.client.js"
}

this is nothing special, it's just how webpack expects entries to be defined.

Everything else is generic configuration that's also present on the server side

{
  plugins: [
    new WebpackManifestPlugin({
      publicPath: '',
      basePath: '',
      filter: file => {
        return /\.mount\.js$/.test(file.name)
      },
    }),
  ]
}

Then we have the manifest plugin, which checks for files that have the string mount in their name, this is done to make sure that only the entry files are loaded and not random files and we do this by specifying a specific extension type for the file.

Some frameworks use a islands folder to separate islands from entry files. We instead separate the entry files from islands and have the user decide what mounts as an island and what doesn't.

The above WebpackManifestPlugin generates a manifest.json file in dist/public/js which has the bundled file names which we were using in the lib/html.js file.

.babelrc

This is the last part of the configuration, where you ask babel to make sure that the JSX runtime it uses is from preact and not react.

Pretty self explanatory, but if you need details about the option, please go through the docs of babel and @babel/plugin-transform-react-jsx

{
  "plugins": [
    [
      "@babel/plugin-transform-react-jsx",
      { "runtime": "automatic", "importSource": "preact" }
    ]
  ]
}

Folders

We can now move on to each folders' significance here.

Note: Please know that you can mix and match the folders if needed, just make sure the configurations are edited to handle the changes you do. If not, the current structure is good enough for most applications

client

The src/client in this main branch is used to write the mount point code that get's sent with the rendered html.

You add selective mounting based on pages and selectors that you wish to use, even though it would fetch multiple JS files, these files are never to have anything more than the mounting code , your islands should be self serving and self reliant. You can however send an initial dataset from the server as a data-* attribute but this has to be serializable data or will be lost.

You can also add a wrapper around to create a island manually, but web-components are not widely supported so if using for a legacy level support system, you are better off manually mounting like mentioned above.

example:

// src/client/index.mount.js

import { h, hydrate } from 'preact'

// setup goober
import { setup } from 'goober'
setup(h)

// can be moved to a util file and used from there,
// in this file as an example for now.
const mount = async (Component, elm) => {
  if (elm?.dataset?.props) {
    const props = JSON.parse(elm.dataset.props)
    delete elm.dataset.props
    hydrate(<Component {...props} />, elm)
  }
}

const main = async () => {
  // lazy load and re-mount counter as a client side component if needed
  // A better way would be to check if the `counter` element exists on
  // the DOM before even importing the component to avoid un-needed
  // JS downloads.

  const Counter = (await import('../components/Counter.js')).default
  mount(Counter, document.getElementById('counter'))
}

main()

components

The name is pretty self explanatory, since we aren't doing any segregation here as to what is and what isn't an island, you can shove all your components here like you normally would.

layouts

These are separated since I like to keep the layouts far away from components since sometimes they have more than just rendering conditions. It's not needed in this specific case because in most cases you'd be running your layouts on the server and not on the client.

lib

Contains common helper funcs for both client and server, since both are bundled separately and dependencies will be inlined as needed.

pages

This folder acts as the storage for templates. So anything that the server will be rendering as a page would be put in here. The ability to use layouts and other components like a normal preact app helps with building composable templates but still it's easier to just have them separate from the actual component code.

public

Stuff that needs to be delivered statically by express is just put here, webpack takes care of copying the whole thing to the final folder.

server

Self explanatory, server sided files, in most cases you'd like to move routes to a separate file and maybe add in middlewares to add a helper function to render preact components for you.

Something like this is definitely a part of the server and not going to be client sided so just keep it in this folder.

Example

app.use((req, res, next) => {
  res.render = (comp, data) => {
    return res.write(preactRenderToString(h(comp, { ...data })))
  }
})

// and somewhere else in the app

const handler = (req, res) => {
  return res.status(200).render(Homepage, { username: 'reaper' })
}

That's actually all the code that contributes to setting up your own partial hydration / island styled hydration with nodejs.

Most of this can be achieved with almost all bundlers and a little more modification to how the configurations are generated, can help you achieve a similar DX to astro though you are better off using astro if you aren't a fan of maintaining configs.