Oops, I accidentally made our website faster by switching to Remix
Echobind cares very much about using the right tools for the job, but we’re pragmatic enough to know when it’s worth while to migrate tools and services to save a couple of bucks.
Recently, we’ve been feeling that way about the hosting for this very website, so we decided to say goodbye to our good friends at Vercel and host our website on Railway. We made this decision to make our monthly costs a little bit more predictable, and to bring more of our sites under the same hosting umbrella.
This presented us with a small problem: Next.js. It pairs wonderfully with Vercel, but requires a bit more setup and infrastructure to run well when self-hosted.
We’ve been exploring alternative frameworks, and decided to see if Remix would serve our needs well. Given that it’s also a React framework, porting everything over should be fairly easy, right?
Migrating from Next.js to Remix
Moving the pages over from Next.js to Remix was actually easier than I expected. The Next.js site used App Router and React Server Components, but very few of those server components were nested.
In most cases, I could copy the page code into Remix, replace the <Link>
tags with the Remix equivalent, and move any data fetching into the Remix loader
function. For the nested components, I changed them so their data was passed as props instead of fetched directly in the component body. That was the bulk of the migration.
We also have a number of redirects which Next.js handles out of the box. In Remix, I had to create a function to process and handle those redirects for each request and run that function in the root route’s loader.
import { pathToRegexp } from "path-to-regexp"; const redirects = [ { source: pathToRegexp("/tagged/:tag"), destination: "/topic/:tag", permanent: true, }, { source: pathToRegexp("/blog-categories/:tag"), destination: "/topic/:tag", permanent: true, }, // ... ]; async function processRedirect(pathname: string) { const paths = await redirects(); for (const path of paths) { const match = path.source.exec(pathname); if (match) { // Match the params in the source to the params in the destination // This only supports a single URL param at this point. We could add more later const destination = path.destination.replace( /^\/.*\/(:.*)\/?$/g, (item: string, group: string) => item.replace(group, match[1]) ); // If we found a match, throw to tell Remix // to redirect to the correct page throw new Response(null, { status: path.permanent ? 301 : 302, headers: { Location: destination, }, }); } } }
Another Next.js convenience that isn’t built into Remix is the <Image>
component, which helps keep page loads fast by correctly sizing and positioning images. Fortunately, our CMS has image resizing built in, so we could have roughly the same effect by putting width
and height
on regular <img>
tags and taking advantage of the CMS.
// Images for blog post cards, resized to the maximum necessary size <img src={`https://cms.echobind.com/assets/${image?.id}?key=blog-post-card`} alt={image?.description || title || ""} width={472} height={250} className={cn( "rounded-t-md grayscale group-hover:grayscale-0 transition-all duration-500 w-full max-h-64 object-cover object-center", { "grayscale-0": fullColor, }, )} />
You might need something a little bit more sophisticated than what we're using. Depending on what CDN you're using, you might be able to take advantage of its image transformation services. Unpic provides a really nice API for automatically creating <img>
tags that take advantage of those image CDN transformations.
Next.js App Router comes with automatic caching and static page generation which you specifically have to opt-out of on a page-by-page basis. This hopefully makes pages really fast, but adds extra burden to developers to make sure the cache is invalidated at the appropriate times, either with Incremental Static Regeneration or On-Demand Revalidation. On our old site, we opted for the latter with automatic triggers in our CMS that sent HTTP messages to our site whenever we changed a post or page.
Remix, on the other hand, relies on Cache-Control
headers, which provides the same basic user experience without needing extra framework-level configuration. I just set a default Cache-Control
header of 'public, max-age=300, s-maxage=3600, stale-while-revalidate'
on pretty much every page. Specifically, this says “Cache this in the browser for 5 minutes, and in a shared CDN cache for an hour. After that hour, the shared CDN cache will keep serving the stale cached content, but will re-fetch fresh content from the origin server. Hey, that’s just like ISR!
One last convenience of Next.js is automatically generating and caching Open Graph images for link embeds. For that one, I followed this guide by Jacob Paris and turned to the same tools Next.js uses internally: Satori, which converts JSX into an SVG image, and resvg, which turns an SVG into a PNG.
import satori from "satori"; import { Resvg } from "@resvg/resvg-js"; export async function generatePng(jsx: JSX.Element) { const phantomFont = await fetch( new URL("https://echobind.com/fonts/PhantomSans0.8-Semibold.ttf"), ).then((res) => res.arrayBuffer()); const svg = await satori(jsx, { width: 1200, height: 630, fonts: [ { name: "Phantom Sans", // Use `fs` (Node.js only) or `fetch` to read the font as Buffer/ArrayBuffer and provide `data` here. data: phantomFont, weight: 300, style: "normal", }, ], }); const resvg = new Resvg(svg); const pngData = resvg.render(); return pngData.asPng(); }
To avoid needing to generate these image every time a link is embedded, I upload the image to our CMS after it's generated. If the image for a post is already in the CMS, it just serves that instead of generating it again.
In short, it took a bit of extra effort to switch to Remix. And I thought the payoff would just be an easy time to host (it was incredibly easy to put this new site up on Railway). I wasn’t expecting any other kinds of improvements or performance gains, so when we ran some benchmarks, I was shocked by what we saw.
The Results
First off, Lighthouse. This is what our site looked like on Next.js:
And this is what our site looked like on Remix:
Again, that’s a Remix site hosted on Railway. Tough to say what exactly caused that 2 point performance improvement, but we won’t complain.
Then we tested it on ahrefs, to make sure all of our links and SEO juice was still good. We were shocked to find our health score go up by 3% to 98%, our internal URLs with errors count go down by 94 to 11, and shockingly the number of oversized images dropped from 96 to 2.
In other words, what started off as a migration turned into a performance optimization.
And of course, this isn’t to say anything negative about Next.js or Vercel. They’re great tools and products built by great people. But switching our site to Remix and Railway paid off in spades. Not only that, but the Remix development experience feels more natural and straightforward to our developers, which makes building new features and maintaining our website all the more enjoyable.
If you’re looking for a great framework, pick Remix. And if you’re looking for a great team to help you build (or migrate) your site or app with Remix, pick Echobind.