Hey, the name's reaper. I work on various experimental solutions for working with web apps, ASTs, and generally play around with languages. Mentioning all this because I'm not some random dude who started working on the web yesterday and is teaching you things today.
Moving on, I've advocated the use of custom setups and simple build tooling quite a bit on my twitter/X profile, and so this post is also in the same direction—except I plan to show you how simple it actually is.
This is a long-ass post, so you might want to start this when you have a clear mind and can follow along. You don't need to, but it'd be nice.
I'm sure you're aware of what meta frameworks are and when to use them. We aren't here to call them bad or call my approach good—it's a simple post going through the process of simplifying what the meta frameworks abstract.
What do meta frameworks solve that we need to replicate?
The last thing is what makes them really attractive to most developers-beginners and advanced devs alike. So, we won't be adding any developer experience in this post. Let's focus on the other things.
There's a ton of libraries for this: React, Preact, Vue, Svelte, Solid, Hyperscript, yada yada ya...
We'll use Preact, 'cause it's my post.
Here's what UI composition would look like in Preact:
import { useState } from "preact/hooks";
const Layout = ({ children }) => <div className="container">{children}</div>;
const Counter = () => {
const [count, setCount] = useState(0);
return (
<Layout>
<button onClick={() => setCount(count + 1)}>{count}</button>
</Layout>
);
};
Now, let's say I wanted to render this in the browser, I'd use the render
function from the lib:
import { render } from "preact";
//... Counter Component
render(<Counter />, document.getElementById("app"));
Great, we have UI composition. Let's target SSR next.
SSR, or Server-Side Rendering, is a way to simplify the composed UI into a simpler form for the target. There can be different renderers; we will be targeting the browser, so our renderer will do things so a browser can handle/render it.
The point is to be able to construct structured trees of what needs to be rendered, and each one of the UI libraries provides some way or another to do this. Vue and Svelte provide a compiler, (P)react creates a VDOM object. This makes it easy for us to traverse through and use the structure to our liking— a.k.a. the renderer can be anything.
For example, we could convert them into serializable JSON and then convert that JSON back to a tree and then re-construct the component and ask it to render in the browser (some libs do this, research to find out).
To keep things simple, we are going to convert this structured/vdom tree into
HTML using preact-iso
. You can also use preact-render-to-string
(they are
the same library, but preact-iso
provides other things that we need in this
post).
Let's see what a simple SSR with Node.js would look like:
// server.js
import { createServer } from "node:http";
import { prerender } from "preact-iso";
import { useState } from "preact/hooks";
const Counter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
const handleRequest = async (req, res) => {
const { html } = await prerender(<Counter />);
res.setHeader("content-type", "text/html");
return res.end(html);
};
createServer(handleRequest).listen(3000, () => {
console.log("Listening on :3000");
});
It's a little more code than I'd like to explain, but here's what we are trying to do:
node:http
for Node.jsCounter
component.What you'll see here is that when you run this with node server.js
, you get an
error like so:
return <button onClick={() => setCount(count + 1)}>{count}</button>
^
SyntaxError: Unexpected token '<'
at compileSourceTextModule (node:internal/modules/esm/utils:339:16)
at ModuleLoader.moduleStrategy (node:internal/modules/esm/translators:168:18)
at callTranslator (node:internal/modules/esm/loader:428:14)
at ModuleLoader.moduleProvider (node:internal/modules/esm/loader:434:30)
at async link (node:internal/modules/esm/module_job:87:21)
This is where the third feature comes in: the meta frameworks take care of code transforms, bundling, and minification.
Since JS doesn't come with macros or a way to make modifications at build time, we have build tools to help us with that. You see, JSX isn't exactly a part of the JS spec, so we need something that can build this file into pure JS so we can run it with Node.
We'll use esbuild
to help us with this. It's not the only tool for this task,
but I want to establish tools that are written by people who are very thoughtful
about how they add features, and that keeps me from having to update this post
every few months.
// build.js
import esbuild from "esbuild";
await esbuild.build({
entryPoints: ["./server.js"],
format: "esm",
jsx: "automatic",
outdir: "./dist",
loader: {
".js": "jsx",
},
bundle: true,
platform: "node",
jsxImportSource: "preact",
external: ["preact", "preact-iso"],
});
With this, we now run node build.js
first and then run
node ./dist/server.js
, and you now have a server on localhost:3000
that
renders a button. The button doesn't do much, but it's there—it's the button you
created on the server, SSR!!!
Moving on...
The button's useless without the counter increasing, which is what we wanted it to do, but because we just pushed some HTML to the browser, it doesn't know how to make the button interactive, and we'll have to fix this.
This is something most frameworks abstract away beautifully, so most people who've never written a custom implementation feel like there's a super complicated setup behind this.
We need to accomplish three things now:
Let's move the UI logic to a separate file. I'll call it App.jsx
, and I'm
going to rename the component to App
instead of Counter
.
// App.jsx
import { useState } from "preact/hooks";
export const App = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
Moving to the second step, our server.js
now looks like so:
import { createServer } from "node:http";
import { prerender } from "preact-iso";
import { App } from "./App.jsx";
const handleRequest = async (req, res) => {
const { html } = await prerender(<App />);
res.setHeader("content-type", "text/html");
return res.end(html);
};
createServer(handleRequest).listen(3000, () => {
console.log("Listening on :3000");
});
If you do a node build.js
and node ./dist/index.js
, things should still be
working.
Now, step three: I need the browser to mount this app again. Let's ask Preact to
hydrate
or mount the app again.
// browser.jsx
import { hydrate } from "preact-iso";
import { App } from "./App.jsx";
hydrate(<App />, document.getElementById("app"));
We have a document.getElementById("app")
here, which doesn't exist in the HTML
we've been sending from the server, so let's fix that in the server.
import { createServer } from 'node:http'
import { prerender } from 'preact-iso'
import { App } from './App.jsx'
const handleRequest = async (req, res) => {
const { html } = await prerender(<App />)
+ const finalHTML = `
+ <body>
+ <div id="app">
+ ${html}
+ </div>
+ </body>
`
res.setHeader('content-type', 'text/html')
- return res.end(html)
+ return res.end(finalHTML)
}
createServer(handleRequest).listen(3000, () => {
console.log('Listening on :3000')
})
We added wrapper HTML code that contains the element with the id
of app
.
Does that solve our problem though? Try building and running again.
We still have a useless button...
Why? Because while we added the wrapper, there's no way the browser knows that
it needs to fetch the browser.jsx
file. And another problem: it's a file with
JSX code, which means we need to transpile/compile it to pure JS so the
browser can understand it.
Let's deal with the latter problem first. We'll modify the build.js to also
create a bundle for the browser, and the output will be in dist/client
. You
can check the output of both the server and the browser builds to see what's
being generated—should help you understand a bit more about the build tools.
// build.js
import esbuild from "esbuild";
await esbuild.build({
entryPoints: ["./browser.jsx"],
format: "esm",
jsx: "automatic",
outdir: "./dist/client",
loader: {
".js": "jsx",
},
bundle: true,
platform: "browser",
jsxImportSource: "preact",
});
await esbuild.build({
entryPoints: ["./index.js"],
format: "esm",
jsx: "automatic",
outdir: "./dist",
loader: {
".js": "jsx",
},
bundle: true,
platform: "node",
jsxImportSource: "preact",
external: ["preact", "preact-iso"],
});
Cool, now we have a browser build and a server build where we can write JSX. Back to the original problem: how does the browser know where to fetch the client file from?
Two steps:
We need to make some mods so that the browser can ask the server to send a specific file. This is how it would get CSS files in the future, but for now we want it
to get the compiled browser.js
file from the ./dist/client
folder.
Let's use a simple library called send
to help us with this. A few things: we
are writing this code with the assumption that the folder structure looks like
this:
.
├── App.jsx
├── browser.jsx
├── build.js
├── server.js
├── package.json
│---------------- # Build Output
└── dist
├── client
│ └── browser.js
└── server.js
So, the code we run with node dist/server.js
belongs in the directory dist
,
and so does the client
folder. Hence, we need to program the server to keep
that in mind.
Next, we want to ask the server to make sure it only serves the static files
when the request URL starts with /assets
.
Let's make these modifications.
// server.js
import send from "send";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const handleRequest = async (req, res) => {
if (req.url.startsWith("/assets")) {
// remove the prefix `/assets` and only use the rest of the path to serve the file
// eg: /assets/index.js will become `/index.js` and it will send the `index.js` file
// in the `client` folder which is defined as the `root` option for send.
return send(req, req.url.slice("/assets".length), {
root: join(__dirname, "./client"),
}).pipe(res);
}
//... SSR code
};
At this point you should be able to build and run the server and open the browser to
localhost:3000/assets/browser.js
, and it should show you your bundled
JavaScript code in the browser. Let's change the wrapper HTML code to send this file as a part of our rendered app
const handleRequest = async (req, res) => {
if (req.url.startsWith("/assets")) {
// remove the prefix `/assets` and only use the rest of the path to serve the file
// eg: /assets/index.js will become `/index.js` and it will send the `index.js` file
// in the `client` folder which is defined as the `root` option for send.
return send(req, req.url.slice("/assets".length), {
root: join(__dirname, "./client"),
}).pipe(res);
}
const { html } = await prerender(<App />)
const finalHTML = `
<body>
<div id="app">
${html}
</div>
+ <script type="module" src="/assets/browser.js"></script>
</body>
`
res.setHeader('content-type', 'text/html')
return res.end(finalHTML)
}
Also make sure to update the build.js
file to exclude send
in the external deps in the server's build.
import esbuild from "esbuild";
await esbuild.build({
entryPoints: ["./browser.jsx"],
format: "esm",
jsx: "automatic",
outdir: "./dist/client",
loader: {
".js": "jsx",
},
bundle: true,
platform: "browser",
jsxImportSource: "preact",
});
await esbuild.build({
entryPoints: ["./server.js"],
format: "esm",
jsx: "automatic",
outdir: "./dist",
loader: {
".js": "jsx",
},
bundle: true,
platform: "node",
jsxImportSource: "preact",
- external: ["preact", "preact-iso"],
+ external: ["preact", "preact-iso", "send"],
});
Now, if you run node build.js
and node ./dist/server.js
, you should have a
working button that increases the counter!!!
In a way, we're kinda done. Next up, there are things like routing, adding more platform support, abstracting away Node's server layer so you can use it in different runtimes (for example, Cloudflare or Deno). I'll cover that in the next post.