I spent the past week transitioning the Mailbrew frontend, a standard single-page application (SPA) created with Create React App (CRA), to NextJS and wanted to share my experience since there might be a lot of you pondering the same move.
Why?
The impetus for transitioning to Next was our frustration with the shitty social cards that would pop up when people shared public brew links to Twitter.
There is no easy way to handle dynamic server-side-rendered meta tags for a standard CRA. You need to host it behind a server and manually handle every HTTP request, making it hard to take advantage of the awesome CDNs that modern frontend hosts like Vercel and Netlify offer.
We tried to fix this with a bad workaround that worked poorly because it relied on making people click a button to copy a custom share link (share.mailbrew.com
). Most people missed it, sharing the unoptimized URL instead, resulting in tweets like this:
We think that people skip unoptimized social cards because we frequently do.
NextJS fixes this. You are a function definition away from making any page server-side rendered:
export default function BrewPage({ brew }) {
// ...
}
export async function getServerSideProps(context) {
const brewId = context.params.brewId;
const res = await axios.get(`https://api.mailbrew.com/brews/${brewId}`);
return { props: { brew: res.data } };
}
By defining the getServerSideProps
function, the BrewPage
component has immediate access to the brew without fetching it client-side. It can render all the needed meta tags needed to have cool social images, and it can do so statically on the server, so this works when sharing the links on Twitter and other social networks.
This approach also has the benefit of making it easier for Google to index these pages and speed uploading. A win on so many fronts that we were no longer able to ignore.
What else we will do with this
Another cool thing that this transition enables is to merge mailbrew.com
(our marketing website) and app.mailbrew.com
(the progressive web app). This allows logged users that visit mailbrew.com to get to the app quicker and to have a single domain for Google to optimize our SEO. Many people create brews and link them on their sites; it's free backlinks for us if we handle everything under one domain.
We'll also be happy to move away from Gatsby for our marketing site. It has served our needs, but it is currently slowing us down with its build times and its build pipeline complexities. NextJS is faster, simpler, and seems to be moving in the right direction on lots of fronts.
Slight digression on why I think NextJS > Gatsby (feel free to skip it):
An excellent example of the two projects' different approaches is their solutions to image optimization: resizing and compressing images for optimal performance on different devices. Gatsby optimizes images locally at build time, while NextJS does this via their serverless functions when they are loaded. The difference in build times is jarring. Optimizing 100-200 images with Gatbsy quickly bumps your build time to 5-10 minutes. Caching helps, but we have found it to be unreliable and hard to set up.
The other thing that's a bit weird about Gatsby is the whole gatsby-node
system. It's a separate file where you put all your build-time logic to generate pages by fetching external content (we use it to create the blog and some other marketing/SEO pages). It's a completely decoupled system from the actual page components, and it is hard to debug. Next offers a unified experience where you turn on server-side rendering or static rendering by just exporting a function from your page component module, resulting in a better developer experience.
A thing that we will miss from Gatsby is its rich plugin ecosystem. The Next programming model is so much nicer though that we don't mind having to write a bit more logic on our own.
How much work was this transition
Routing
The biggest hurdle in this transition was adapting to the NextJS routing model, i.e., matching components to URLs.
On the CRA front, we were using react-router
. Next uses filesystem-based routing. I was skeptical at first: will we be able to put all our routing logic in the file system? Would it look weird?
I am pleasantly surprised at how clean it looks and how much more navigable our src/pages
directory looks now.
Before and after:
We now have a single source of truth for routes and no weirdness in component naming. We lost a bit in flexibility and the ability to use a Router within a Router, which we were doing in a couple of places, but oh well. It was probably not a good idea in the first place. In these scenarios (like in our brews editor), we are now using catch-all routes that make that section of the app work like a standard single-page app navigation-wise.
We also had to strip away all the react-router hooks (useMatch
/useLocation
/...) in favor of NextJS's useRouter
, which gives you a single object to handle all your routing needs. All URL parameters are merged (both query parameters and the ones passed via /[url]/[parameter]
) in a single router.query
object.
Another thing I am not particularly fond of is the fact that Next doesn't give you the ability to push state with navigation actions.
The browser natively allows you to push a path and some state via its history API, like this history.push('/edit/12', {showModal: true})
. The URL bar shows /edit/12
, and you can access the state pushed with the second parameter without the user having to see it. This is very clean, and we used it in a couple of places in Mailbrew.
With NextJS, doing this is a bit messy. You have to router.push('/edit/12?showModal=true', /edit/12')
: the first parameter is the path that's actually pushed, the second is the one that's shown in the address bar. This way, you can access the parameter via router.query.showModal
. I think directly exposing the history state API (which they are probably using under the hood) would result in better usability.
Server-side rendering is weird
Here come the single worst parts of this transition.
NextJS renders all pages on the server at build time, creating a compiled version (static HTML) that is then hydrated on the client.
The developer should not care about this implementation detail, but you are too often reminded of it with weird behaviors and bugs in practice. I am sure that the fact that we were transitioning our codebase made this worse because subtle bugs were harder to spot, but I don't like having to think about SSR at all.
When using Next, you need to keep in mind whether your code is running on the server or the client at all times (even if you don't use SSR at all).
When running on the server, you can't refer to window
or your app won't compile, your useEffect
's won't get called, and accessing router.query
will return empty objects. You get used to these, but I think all these edge cases made the transition harder. I think these rough edges will get smoothed out in the future since SSR is getting quite a lot of attention in the community.
PWA
next-pwa is excellent. We just went with the defaults, and it was a smoother experience than with CRA.
Importing images/SVGs
You can't import your SVGs and images from JavaScript like you do in CRA, but there is a plugin for that too. It's next-images. Remember to set the svgo
option to false if you get weird results for your SVGs; they are getting optimized under the hood in a lossy way. Our brew coffee mug got murdered by this.
Final thoughts
I like NextJS. It's a higher-level abstraction on top of React that makes your codebase tidier with convention over configuration while still being extensible in the right places.
A super-smart team works on it, which means that your app and developer experience gets better with each Next update for free. There are some quirks, but they are really quickly getting worked out.
It increased the scope of what we can build with minimal effort and puts us in the right place to continue having a cutting-edge web app for the years to come.