Routing for the Modern Frontend

7 months ago
Written by
Fouad Matin

Frontend has become complicated. Gone are the days where you cram everything into a single-page app with a giant bundle.

This blogpost is adapted from talks I gave at Cloudflare Tech Talks in 2023, you can find the original slides: here (or at the end)

Today, we have a number of frameworks, libraries, and tools to pick from — Next.js, Astro, Svelte, Docusaurus, or Vue. That's just the tip of the iceberg.

There are so many tools you could use to build a frontend: Vite, Snowpack, Webpack, Rollup, Parcel, Gatsby, Nuxt, Sapper, Eleventy, Hugo, Jekyll, VuePress, Gridsome, Scully, and the list goes on.

In our case (Indent), we use Next.js for our marketing site and Docusaurus for our docs both hosted on Vercel which enables us to have niceties like per-branch deployment previews and multiplayer comments so anyone on our team can contribute.

For our main app, we also use Next.js, so why not consolidate it with our marketing site? Well, we have to follow compliance and security requirements from our customers like allowing customers to self-host and strict code review controls.

The common problem with splitting into separate apps is that you have to manage routing between them.

You can't just a regular next/link or Link component from react-router-dom to link to a page in another app. You have to use routing-aware links that check if it's the linked route is part of the current app, another Next.js zone, or a different framework.

And maybe one of the worst parts is separate domains for each app. How many times have you gone to, just to get redirected to index page?

It's a terrible experience.

Instead, we use Cloudflare Workers to route between the apps and merge into one canonical domain:
└── /Next.js for marketing — Vercel
└── /docs — Docusaurus for docs — Vercel
└── /home — Next.js for product — Google Cloud

The worker code starts by adding security headers to every response:

addEventListener('fetch', event =>
async function handleRequest(event) {
try {
if (event.request.method === 'GET') {
let response = await serveAsset(event)
return await addSecurityHeaders(response)

In Next.js, we also have to add an asset prefix:

/** @type {import('next').NextConfig} */
const isProd = process.env.NODE_ENV === 'production'
const assetUrl = process.env.VERCEL_URL || ''
const assetPrefix = isProd ? `https://${assetUrl}` : ''
module.exports = {

And then we can serve the asset from the correct deployment:

async function serveAsset(event) {
const url = new URL(event.request.url)
let { pathname, search } = url
let cache = caches.default
let response = await cache.match(event.request)
if (response && response.status < 400) {
return response
if (shouldServeDashboard(pathname)) {
// Remove assetPrefix for dashboard Next.js App
} else if (shouldServeDocs(pathname)) {
// Remove assetPrefix for docs Docsaurus 2.0 site
} else {
// Serve marketing Next.js App
if (response.status < 400) {
const res = response.clone()
event.waitUntil(cache.put(event.request, res))
return response

Have any questions?

Feel free to reach out (@fouadmatin on X) or check out the slides!

Try Indent for free.