During the last year, I've become completely enthralled by the worlds of both frontend web framework development and modern web standards/conventions (such as web components, unbundled development, and so on).
With a goal of trying to combine these two worlds I've been capturing my research, experimentation, and takeaways in the form of Delgada –– a web framework for building slim multi-page websites.
While Delgada is still under very active development, I recently took a step back to synthesize my learning, which resulted in a surprisingly feature-rich server-side rendering (SSR) framework, implemented in just 37 lines of code.
Features of this SSR framework include:
- Ship zero JavaScript by default
- Expressive markup and styling using tagged templates
- Island-based architecture via web components
- No build step
- Zero configuration
The point of this is not to provide a feature-complete implementation of SSR by 2022 standards, but to show that one can get shockingly far with very little code by building on top of the web standards and conventions available today. Think of it as a celebration of the modern web and what it enables.
In the rest of this post, I'll talk about the features, conventions, and syntax of this framework and discuss how it's enabled.
Finally, the source code (along with a demo) for the SSR implementation can be found in this GitHub repo.
House-keeping complete. Let's dive in!
Project structure
We'll start with a birds-eye-view of a basic demo project structure to get oriented with the conventions of using this framework.
The goal of this structure is to emulate modern web conventions and should hopefully feel straightforward and familiar to most reading this.
my-website/
├── public/
│ ├── favicon.png
│ └── global.css
├── src/
│ ├── components/
│ │ ├── SomeComponent.js
│ │ └── AnotherComponent.js
│ └── pages/
│ ├── About.js
│ └── Index.js
├── package.json
└── server.js
At the root of the project are the server and package.json files. A public
directory contains all the static assets and a src
directory contains the components that will be rendered server-side.
The server
Below is an example server.js
file. It contains, amongst other things, the primary API of the SSR implementation (which I'll just call slim-ssr
going forward).
import { register } from 'slim-ssr';
import { Index } from './src/pages/Index.js';
import { About } from './src/pages/About.js';
import express from 'express';
const routes = [
{ path: '/', component: Index },
{ path: '/about', component: About },
];
const islands = ['WebComponent.js'];
const app = express();
register(app, routes, { islands });
const port = 3000;
app.listen(port, () => {
console.log(`Listening on http://localhost:${port}`);
});
A function called register
is the first introduction to slim-ssr
. The register function is responsible for setting up and handling the routing/file serving of a slim-ssr
website.
Another thing you might notice is that Express is being used as the underlying server. For slim-ssr
, this keeps things simple and gives a solid groundwork to build on top of, but it could be easily switched out for another server or server framework.
Routing
Routes are defined as a simple array of objects with a path
and component
property.
const routes = [
{ path: '/', component: Index },
{ path: '/about', component: About },
];
Inside slim-ssr
, routing is handled by these 6 lines of code.
for (const route of routes) {
app.get(route.path, (req, res) => {
res.set('Content-Type', 'text/html');
res.send(Buffer.from(route.component(req)));
});
}
It takes the routes
array discussed above, iterates over every route object, and serves the HTML returned by the component
function at the endpoint defined in path
. Also notice that the component function is passed the client request (i.e. component(req)
) –– we'll come back to this later.
Serving web components/islands
Web components/islands (which will also be discussed later in more depth) are registered as an array of strings, where each string is the name of a web component file in the src/components/
directory.
Each file will be served to the client at the root URL (/
) of a slim-ssr
website. So if there's a web component called WebComponent.js
, it will be served at /WebComponent.js
.
const islands = ['WebComponent.js', 'AnotherWebComponent.js'];
In slim-ssr
, the following code enables this behavior.
if (options.islands) {
for (const island of options.islands) {
app.get(`/${island}`, (_, res) => {
res.set('Content-Type', 'application/javascript');
res.sendFile(island, {
root: path.join(process.cwd(), 'src', 'components'),
});
});
}
}
It first checks that an islands
array has been provided (since it's an optional config). For every file name provided, an absolute path to each web component file is constructed (i.e. the current working directory + /src/components/WebComponent.js
) and then served at the root URL.
Static file serving
Similar to Next.js, (and just like the web component file serving above) all files in the public
directory are also served to the client at the root URL via the code below.
app.use(express.static(`${process.cwd()}/public`));
Template syntax
Before discussing components, we need to cover the template syntax of this framework, which will be used to define component markup and styles.
JavaScript has a powerful built-in templating language called template literals (or template strings). A more advanced form of template literals (and what slim-ssr
uses) are something called tagged templates.
In slim-ssr
, an html
and css
tag are defined/exported and can be used to write expressive markup and styling like so:
// Basic markup and styles
html`<h1>Hello world!</h1>`;
css`
h1 {
color: red;
}
`;
// Use JavaScript expressions directly in markup/styles
const name = 'Universe';
const color = 'red';
html`<h1>Hello ${name}!</h1>`;
css`
h1 {
color: ${color};
}
`;
// Conditional rendering/styles
const age = 17;
let darkMode = true;
html`<p>You ${age >= 16 ? 'can' : 'cannot'} drive.</p>`;
css`
body {
background: ${darkMode ? 'black' : 'white'};
}
`;
// Mapping over data to generate markup/styles
const fruits = ['apple', 'banana', 'orange'];
const tokens = [
{ name: 'primary-color', value: 'rgb(210, 210, 210)' },
{ name: 'secondary-color', value: 'rgb(180, 180, 180)' },
];
html`
<ul>
${fruits.map((fruit) => html`<li>${fruit}</li>`)}
</ul>
`;
css`
:root {
${tokens.map((token) => css`--${token.name}: ${token.value};`)}
}
`;
All the above is enabled by just 15 lines of code.
export function html(strings, ...values) {
const parts = [strings[0]];
for (let i = 0; i < values.length; i++) {
if (Array.isArray(values[i])) {
for (const value of values[i]) {
parts.push(String(value));
}
} else {
parts.push(String(values[i]));
}
parts.push(strings[i + 1]);
}
return parts.join('');
}
export const css = html;
The html
function accepts an array of strings and an arbitrary set of value arguments (which represent JavaScript expressions that may exist in a template). It builds up these different parts into an array of strings that are then joined and returned as the final rendered HTML.
It also notably has some special logic for handling expressions that map over arrays of data to generate markup/styles –– something that is not cleanly handled in regular template literals.
The css
function is simply just the html
function re-exported with a different name.
A quick note on developer experience
By default, tagged templates will be rendered/treated as strings in code editors which results in a less than ideal developer experience when writing component markup/styles. This, however, can be changed with extensions/tooling.
In the case of VS Code, installing the lit-html and es6-string-html extensions make a world of difference while writing HTML/CSS in tagged templates. They can be used to add a ton of helpful features like syntax highlighting, IntelliSense, quick hover info, HTML tag folding, and so on.
Emmet support inside tagged templates can also be enabled in VS Code by changing the "Emmet: Include Languages" setting and adding mappings for "javascript": "html"
and "typescript": "html"
.
Component model
In 2022, web components are living in a bit of a weird teething phase when it comes to SSR. The Declarative Shadow DOM –– which is the API that will enable web components to be server-side rendered –– is only supported in Chromium-based browsers at this time.
This means if web components are adopted as the sole component model of slim-ssr
, it would fail to reach its stated goal of shipping zero JavaScript by default. That is to say, in any non-Chromium-based browser, client-side JavaScript would be required to render UI that only needs HTML and CSS.
In the future, it should be possible to use web components for rendering static and dynamic UI server-side, but for now, we have to look elsewhere for defining static content. Lucky for us, it's possible to achieve an expressive component model that can render static content server-side using functions and the tagged templates discussed above!
Static components
An idea I've been playing with during the last few months while creating Delgada is to have a distinct separation between components that are static (i.e. send HTML/CSS to the client) and components that are dynamic (i.e. send HTML/CSS/JavaScript to the client).
It's a design decision that I have come to really enjoy and so I'm using it here.
To quickly break it down:
- Static components are functions that return a string of HTML
- Static component props are function arguments
- Static component styles are variables that contain a string of CSS
import { html, css } from 'slim-ssr';
export function Greeting({ name }) {
return html`<h1>Hello ${name}!</h1>`;
}
export const styles = css`
h1 {
color: red;
}
`;
To use a static component simply import and add the component function within the markup of another static component.
To correctly pick up the styles of a component, they also must be imported and added to the styles of the target component as shown in the below code snippet.
import { html, css } from 'slim-ssr';
import { Greeting, styles as GreetingStyles } from 'Greeting.js';
export function Index() {
return html`
<!DOCTYPE html>
<html lang="en">
<head>
<title>Home</title>
<style>
${styles}
</style>
</head>
<body>
${Greeting({ name: 'Reader' })}
<p>This is the home page.</p>
</body>
</html>
`;
}
export const styles = css`
p {
color: blue;
}
${GreetingStyles}
`;
Using the client request object
As briefly mentioned earlier, components that are defined in the routes
object in server.js
will be passed a client request object that can be optionally used.
This request object can be used to enable features such as conditional rendering based on request parameters. For example, the component below uses a URL parameter to render a greeting.
import { html, css } from 'slim-ssr';
export function Hello(req) {
const name = req.params.name;
return html`<h1>Hello ${name ?? 'Person'}</h1>`;
}
A name can be added to the end of the page URL in the form /hello/{name}
. If no name is provided the greeting is conditionally rendered to return "Hello Person" as the default.
In server.js
a new route is added that uses Express's parameter syntax.
const routes = [{ path: '/hello/:name?', component: Hello }];
Finally, since the request object is only passed to the components directly contained in routes
if a child component needs access to the request object it will need to be passed down as a prop.
Dynamic components / islands
Islands architecture (or "component islands") is a method of building websites that has really come in vogue during the last year. As Jason Miller describes in his 2020 article introducing the concept:
The general idea of an “Islands” architecture is deceptively simple: render HTML pages on the server, and inject placeholders or slots around highly dynamic regions. These placeholders/slots contain the server-rendered HTML output from their corresponding widget. They denote regions that can then be "hydrated" on the client into small self-contained widgets, reusing their server-rendered initial HTML.
It's an architecture that is great at isolating JavaScript to only the parts of your website that need it. In the case of slim-ssr
websites, we'll be accomplishing this architecture via web components.
Basic usage
Given a <counter-button>
web component (that increments a count on every button click), it can be added to a web page by using the counter button in a static component and then manually linking to the web component file (i.e. /CounterButton.js
) in a script tag. Nothing crazy at this point.
import { html } from 'slim-ssr';
export function Index() {
return html`
<!DOCTYPE html>
<html lang="en">
<head>
<title>Home</title>
<script type="module" src="/CounterButton.js"></script>
</head>
<body>
<counter-button></counter-button>
</body>
</html>
`;
}
Some will have noticed, however, that this doesn't actually meet the definition of islands architecture.
We've created a placeholder that will be hydrated on the client into a small self-contained widget, but there is no server-rendered HTML at this point (since we're not using the Declarative Shadow DOM API).
Enter: Pascal Schilp's writing on SSR and custom elements.
In the article, Pascal points out that any markup nested inside a web component can be conditionally styled during the time it takes for the web component JavaScript to be executed with the following CSS selector.
web-component:not(:defined) button {
/* Apply arbitrary styles to a button nested
inside <web-component> while it's not defined. */
}
We can take this fact and restructure the counter button so that a <button>
is accepted as a slotted element to achieve the server-rendered HTML aspect of islands architecture.
By simply copying and pasting the initial state of the <counter-button>
and its associated styles into the static component, website visitors will see a button that looks like the final hydrated button before its JavaScript has been run.
A nice bonus: This will also address the issue of flash of undefined custom elements (FOUCE) that web components often fall prey to.
<counter-button>
<button>Clicked <span id="count">0</span> times</button>
</counter-button>
counter-button:not(:defined) button {
background-color: #efefef;
color: black;
border: 2px solid #000;
border-radius: 8px;
padding: 6px 10px;
}
counter-button:not(:defined) button:hover {
cursor: pointer;
background-color: #e6e6e6;
}
/* ... other static component styles ... */ ;
One more scenario worth mentioning (that is also discussed in Pascal's article), is that we can take advantage of the fact that arbitrary styles can be applied to the button to better represent its current state.
In this case, when the component is not hydrated it will not be interactive. So instead of styling the button normally, it could instead be styled to imply it's in a disabled state.
counter-button:not(:defined) button {
background-color: lightgrey;
color: darkgrey;
border: 2px solid #000;
border-radius: 8px;
padding: 6px 10px;
}
counter-button:not(:defined) button:hover {
cursor: not-allowed;
}
Once the component is hydrated the normal button styles defined inside the web component will kick in and override the disabled styles.
Clearly, exposing the internals of every web component as slotted children is not the most ideal solution, but it does at least meet the stated goals of slim-ssr
and starts to demonstrate what a world with full Declarative Shadow DOM support will look like –– which I think is pretty exciting.
While this conclusion may be discouraging to some, I think a recent tweet by Danny Moerkerke is a great reminder of how to think about web components:
Web Components are a standard so features take longer to be added, contrary to frameworks that can basically add any feature they like.
It doesn’t mean that Web Components are bad or will never be ready.
Be patient, standards take time.
So yes, while it's unfortunate that the SSR story of web components is still in a teething phase, I hope the ideas above act as a catalyst of excitement for what can still be accomplished today and the fact that there's a lot of work being done to improve this story in the future.
Taking these ideas further
At only 37 lines of code, there's a lot of headroom to play with and ways to push the ideas laid out above even further. Some ideas that I've already implemented in Delgada or I'm actively exploring are:
File-system based routing
For not too much code, file-system based routing can be achieved. Delgada already does this to statically generate websites.
It's a fairly straightforward case of recursively iterating through all the static component files in the src/pages/
directory, executing the component code to render final HTML output, and then writing those outputs to files in a build
directory –– making sure to mirror the directory structure inside src/pages/
in build
.
Automatically add script tags for islands
A minor quality of life improvement that requires very little code is automatically inserting a script tag into every page that uses web components. Here's an implementation of this concept in Delgada.
Optional inline styles
Some may have noticed that all the page styles in the code snippets above were eventually inlined.
<head>
<style>
${styles}
</style>
</head>
While this is great for improving first-time page loads, it's not so great for web pages that have a lot of recurring visitors who would benefit from an external CSS file that can be cached by the browser.
For about 20 lines of code, the option to define styles as inline or as an external file is possible.
In Delgada, this manifests as the ability to define a metadata
object for each page with various configuration options. One of them is the ability to change whether the styles of a given page should be inlined or not.
export const metadata = {
// Will generate a separate CSS file for the given page
inlineCSS: false,
};
// ... other static component code ...
Page templates
Another feature that is basically free because static components are just functions is the ability to define page templates/layouts.
Template components can be defined using the same syntax as a static component and accepts a slot
prop. In the example below, a template can be used to reduce the boilerplate of web pages.
import { html } from 'slim-ssr';
export function Template(slot) {
return html`
<!DOCTYPE html>
<html lang="en">
<head>
<title>My Website</title>
</head>
<body>
${slot}
</body>
</html>
`;
}
import { html } from 'slim-ssr';
export function Index() {
return html`<h1>Hello World!</h1>`;
}
To use the template, the routes
array in server.js
simply needs to be updated so that page components are wrapped by the template component.
import { Index } from './src/pages/Index.js';
import { Template } from './src/templates/Template.js';
const routes = [
{
path: '/',
component: () => {
Template(Index);
},
},
];
Delgada takes this one step further by also automatically passing the metadata
object to all templates so that it can be used to pass arbitrary data from a page into a template.
export const metadata = {
title: 'My Website',
inlineCSS: false,
};
import { html } from 'slim-ssr';
export function Template(slot, metadata) {
return html`
<!DOCTYPE html>
<html lang="en">
<head>
<title>${metadata.title}</title>
</head>
<body>
${slot}
</body>
</html>
`;
}
Use a modern runtime like Deno or Bun
Adding TypeScript support to a Node-based web framework can be kind of tricky.
An alternative I've been exploring is to build a web framework on top of a modern runtime like Deno or Bun that supports TypeScript execution out of the box.
Component scoped CSS in static components
I'm also looking into adding scoped CSS support in static components since all the styles currently live in the global scope of any given page.
It's a topic I haven't put too much research into yet, so if anyone reading this has any resources or tips please do send a tweet or DM my way!
Template directives
Pulling inspiration from Lit (a framework for building web components), the templating of slim-ssr
could be vastly improved via "directives."
Directives are functions that customize the way a template expression gets rendered and can either simplify the creation of markup/styles or add extra functionality that doesn't exist currently.
Lit's list of built-in directives offers some great inspiration for what's possible.
Incremental build-time rendering
Another cool optimization that could be added is what Thomas Allmer refers to as "On-Demand to Build-Time Cache SSR" or "Incremental Build-Time Rendering". Others might also know this concept from Next.js as "Incremental Static Regeneration."
The basic idea is to render and send a page request as normal, but also write the rendered HTML to a file that is saved in a cache. If there's a subsequent request made for the same page the cached file will be sent instantly instead of re-rendering everything.
Conclusions
In a time when everyone (or at least everyone in my Twitter bubble 😉) seems to be talking about bloated website bundles and inattentive usage of NPM packages, I've discovered a breath of fresh air and a delightful simplicity in what the modern web can enable in 2022.
It, of course, still has its discomforts and growing pains but it has me really excited for what the future holds. I hope after reading this you might be feeling some of that excitement too.
~~
Liked what you read? Or maybe not? Have a question? Let me know on Twitter!
Top comments (0)