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" }
]
]
}
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()
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.
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.
Contains common helper funcs for both client and server, since both are bundled separately and dependencies will be inlined as needed.
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.
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.
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.