Dynamic server islands
Do you ever find youself wanting to prerender the static parts of your page at build time, and selectively render the dynamic parts on the server for each request?
Think of this very page: most of it is too expensive to render on every request, what with markdown processing, syntax highlighting and whatnot. But there is some dynamic content in my footer, where I display the song I’m currently obsessed with.
Currently obsessed with David Bowie
Eleventy has allowed partial edge rendering ever since its initial support for edge functions. In Astro, this has been a long requested feature but it hasn’t gotten any traction lately. Next.js has an experimental feature called “partial prerendering” which is sorta similar.
Truth is, I’ve been toying around with this idea for months now, and I think I’ve found a framework-agnostic way to achieve it. It’s not perfect, but it satisfies my use cases for now.
Enter partials
Partial HTML pages are the key to achieving this. Many frameworks support partials, but even if your framework doesn’t, you can fake it by returning a regular page or even just a string from an API route handler.
Here’s a high-level overview of how you can create dynamic islands:
- Generate most of the page statically at build time. Put placeholders to mark the dynamic parts.
- In a new route, render the dynamic parts. This route will generate new HTML each time it’s called.
- When the user requests the page, insert the dynamic parts into the placeholders before returning the HTML.
Steps 2 and 3 could be combined in some cases, but I like to keep them separate as a preference.
An example
Let me show you what those three steps look like in practice, by demonstrating how I render the current obsession in my footer.
-
On my static page, I render an empty custom element. Crucially, I also include the styles here, pretending that the element’s content is prepopulated even if it isn’t. Since my blog is built using Astro, I can put the styles in an Astro component. This ensures it will get bundled with the rest of the styles.
Code snippet <current-obsession></current-obsession> <style is:global> @layer components { current-obsession { /* … */ } } </style> -
In a partial page, I query the Last.FM API and return the final markup.
Code snippet --- export const partial = true; const { url, name } = await getTopRecentTrackFromLastFm(); --- <p>Currently obsessed with <a href={url}>{name}</a>.</p>(The Last.FM API details are abstracted out for brevity)
-
Lastly, I put a middleware function in front of my static page. In here, I fetch the partial page, and insert it into my custom element using one of the techniques from server-side custom elements. (The code below is for Vercel Middleware which uses the standard fetch API.)
Code snippet import { parseHTML } from "linkedom"; export default async function middleware(request) { const originalPage = await fetch(request).then((r) => r.text()); const currentObsession = await fetch(MY_PARTIAL_URL).then((r) => r.text()); const { customElements, HTMLElement, document } = parseHTML(originalPage); customElements.define( "current-obsession", class extends HTMLElement { connectedCallback() { this.innerHTML = currentObsession; } } ); return new Response(document.toString, { headers: { "Content-Type": "text/html" }, }); }
That’s it!
I’ve only shown how to build a static page with dynamic islands, but the same technique can also be used to inject a static server island into a dynamically rendered page.
Some considerations
- I would probably only use this technique for very small things, like theming and maybe feature flags. It feels too brittle for anything larger. I would feel more comfortable if it was built into the rendering pipeline.
- Depending on the setup, middleware might not run locally. I get around this by conditionally rendering the partial page content inside my custom element only during development.
- It looks simple enough for a single island, but it does not scale well. You’d have to make N requests or put the content of N unrelated islands in a single partial page.
- I don’t like that I have to manually make a request and manually return a new response. I’m probably losing some important information and optimizations along the way (like streaming).
- The initial request now takes longer, even if the dynamic content is not critical. To help with this, I’m using
stale-while-revalidate.
Update, November 2024: Astro now has built-in server islands, which means all this extra work may no be longer necessary. It works a little bit differently — it relies on multiple network requests, with the responses stitched together on the client rather than on the server. This would be ok for certain kinds of content (especially if “below the fold”), but there are a couple major annoyances:
- You cannot control where the server islands run. Astro has phased away support for edge rendering, so this code will always run on the regular, Node-based serverless functions.
- You cannot control the caching strategy. This is a dealbreaker for me, especially in combination with the first point. Node serverless functions can be too expensive to run on every request, and would benefit from ISR-like caching for high-traffic websites like my blog.