Introduction
If you're just tuning in, the first chapter detailed our approach to creating the SPA (Single Page Application) version of Flowdiver. We explained the reasoning behind our decisions and the challenges we encountered.đ
In this chapter, we'll discuss our experiences in migrating the SPA to a Next.js framework, employing an SSR (Server-Side Rendering) strategy to tackle some of the ongoing "problems."
The Lab
Preparation
To ensure that our decision was the right one, we took some time to evaluate other existing solutions on the market.
Next.js stood out due to its large community, the abundance of examples, and the availability of templates. However, the "recent" shift from a pages router to an app router, along with the introduction of client and server components, and the overall feeling that Next.js might be too cumbersome for our needs, sent us on a quest for new horizons.
Qwik
We just wrapped up our FindLabs corporate website using Qwik and thought we could utilize our newly acquired experience.
Pros
Qwik was incredibly fast and straightforward to set up for SEO and achieving good web metrics. The clear separation between client and server-side code, utilizing useVisibleTask$()
was a breeze to manage and didn't require complex mental gymnastics to understand the migration process.
Cons
The SPA version heavily utilizes Styled Components, and although it's feasible to use the styled-vanilla-extract
library and migrate the code with minimal changes, some parts would still need refactoring since CSS is pre-built during compilation. We've previously used the useStylesScoped$
function while building a corporate website, but it often felt more like a hack than a solid solution.
Another significant challenge was the render props pattern. We heavily rely on this for our table component to ensure a high level of customization and abstraction. However, Qwik's architecture is fundamentally differentâthe framework aims to fragment the code into the smallest possible chunks and load them in parallel. This means it attempts to serialize your render functions, which becomes a mess real fast.
Additionally, the contexts that Flowdiver heavily depends on would be unworkable and would require a complete redesign into a new composition. While these changes are manageable, we wanted to minimize disruption as much as possible.
Considering all these pros and cons, we decided to put this on the bench for a better use case đ.
Waku
While researching server-side components, we discovered Wakuâa minimal React framework that facilitates the use of SSR (Server-Side Rendering) and SSG (Static Site Generation) approaches effortlessly đ.
Pros
Waku struck the perfect balance we were looking forâa compact footprint on both client and server sides, along with a clear division of domains.
The front page did an excellent job of clearly and efficiently explaining the concepts of client-/server-side components: data fetching, SEO, routingâall the puzzle pieces began to fall into place.
We bootstrapped an app and attempted a minimal setup migration. The community and the author on Discord were incredibly responsive, offering valuable tips and explanations on various concepts and strategies.
Sounds like a dream, right? Well, yeah...
Cons
The unstyled concept was functioning flawlessly, but we had to abandon styled-components
almost immediately. The documentation covered CSS modules well, but lacked information on styled components and similar approaches. At that point, we realized that sticking to a well-tried solution might backfire... đ
Similar to Qwik
, the use of contexts in Waku also appeared to be challenging and required significant refactoring to manage props drilling effectively.
Moreover, as we were experimenting with it, new features were continuously being released, making the framework feel somewhat unstable and experimental as well.
Whatâs Next?
In the end, we found ourselves back where we started, but with newfound knowledge and confidence in our choice.
Pros
Next.js stands out as the most seasoned of all React-based full-stack frameworks. With dozens of examples and templates available, and hundreds of resolved issues on GitHub backed by multiple contributors, there's reassurance that it wonât just disappear over the weekend because the original author decided to move on from his project.
Itâs also a match made in heaven for Vercel deployment. True to its promises, some features (notably PPR) are exclusively viable on Vercel due to the tight integration and finely tuned setup.
Cons
Server-side components still didn't make much sense with us at that time đ
However, we decided it was time to shed our apprehensions and start tinkering!
Take 1 - Heads-on!
We opted for the app router
as it appeared more modern and future-proof. Moreover, it seemed like a logical next step from react-router. It also supports server components
, which were crucial for our application. Plus, the layout fragments really seemed promising!
With our mindset focused on minimal changes and limited time commitment, we set up the basics that would allow us to work with styled-components and render them on a blank page. Once this foundation was laid, we began to migrate
(or more accurately, copy/paste đ
) our components into the Next.js-based repository.
One component,
Two components, done!
Errors during compilation,
None!
Well, not exactly. We were soon overwhelmed with errors like:
You're importing a component that needs createContext. It only works in a Client
Component but none of its parents are marked with "use client", so they're Server
Components by default.
â Learn more: https://nextjs.org/docs/getting-started/react-essentials
â ...
But we came prepared, haha! We just needed to tag some files with use client
. So this one, that one... and that one too!
In the end, we marked almost EVERYTHING with use client
and inadvertently exposed our Hasura token to the public domain. Not exactly what we had planned, but hey, at least it was functioning. A living and âbreathingâ system. Yes! đ
Take 2 - We Require More Minerals
The next step was to migrate the rest of the pages. So, we thought, why not transfer about a hundred files at once? What could possibly go wrong? Fortunately, with our root layout file flagged as use client
, everything below it was also tagged as "dirty," and thus the compiler remained silent, untroubled by our actions.
However, trouble started brewing as soon as we began refactoring pages into server-side components...
Hereâs the snag: client components
canât import server components
. Yet, we desperately needed that ThemeProvider
from styled-components
to function correctly. Moreover, various other context providers needed to be positioned at the top, so they were accessible for data reading across the board.
We plunged into the refactoring, attempting to determine what should remain a server component
and what needed to be client-side. Yet, a relentless cascade of - It only works in a Client Component - made it seem like we were running in circles, getting nowhere fast.
It was time to pull back, reassess, and develop a new "pipeline" for how we would transition our SPA into the promised land of SSR.
Take 3 - Slow, but Steady
We stripped everything down to the bare bones and began piecing the pages back together, labeling only the absolutely essential elements as client components. Fortunately, most of these necessities were related to styled-components
and nestled within the styles.tsx
files.
Hooks, particularly useState
and useSearchParams
, were reliable indicators that a file belonged on the client side of the network boundary. However, some could seamlessly receive searchParams
props directly from page.tsx
without switching allegiances.
We tackled the migration one route at a time, and it all seemed to be going smoothly, until we encountered the more complex pagesâlike those for accounts or transactions:
These pages featured some static elementsâlike status and transfers at the topâand more dynamic components controlled by tabs. We considered collecting all the data and distributing it to child components via context, but that approach would only be effective in this specific scenario. The account page, in particular, demanded more diverse data, which would necessitate pulling from the server or refreshing the page with new search parameters.
This led us to the decision to utilize parallel routes for implementing the tabs. This strategy allowed us to establish clear distinctions in data fetching for each tab without the complication of tight coupling. As a result, the render tree remained neat and tidy, while all the logic was appropriately segregated within its own domains:
At this point layout.tsx
in the sub-route /tx/[id]
operates as a component, alongside page.tsx
and all the parallel routes. We used Suspense
to effectively display loading states, ensuring that user experience remains smooth and uninterrupted during data fetching. Importantly, we carefully manage security by not exposing our token when fetching data.
Houston, We Have a Problem
While most of the components and pages function as intended, some fall short...
Take our custom table component, for instance. In the SPA setup, displaying the loading state was straightforwardâwe simply retrieved it from the context. However, in the SSR framework, components like the table header and pagination controls need to be visible before the data is fetched. This necessitated some serious refactoring and clever component rearrangement.
Before, our context provider enveloped the table; now, this needs to be shifted into the children
block because data fetching occurs server-side, across the network boundary. We refactored the code, injecting additional props into the table (since it could no longer access these from the context) and ended up with the following configuration:
While most of the components and pages works as intended, some donâtâŚ
For example, our bespoke table component. In SPA implementation it was easy to show loading state - we can simply pull it from context. But in SSR our table - at least header and pagination controls - should be visible before data is fetched. That was asking for a refactoring and some component juggling.
Previously, we had context provider wrapping up table, now it should be part of the children
block, cause data fetching is on server side of network boundary. We refactored that code, passed extra props to the table (cause now it couldnât read them from context) and end up with following:
AccountTransactionsProvider
is a server component that fetches the data and then passes it into TableDataProvider
via props. And TableDataProvider
is a client component that can properly work with contexts:
That was an Aha!
moment in understanding how to compose server and client components. You canât import
server components from client boundary, BUT, you can render the result of their execution in your client components. You just need to pass them as children
or render props!
So the following composition is perfectly fine:
<RootServerComponent> {/* <--------- SERVER */}
<ClientComponent renderProp={<SDFComponent/>}> {/* <--------- CLIENT */}
{/* the following code will be passed as "children" */}
<SmallServerComponent> {/* <--------- SERVER */}
<InnerClientComponent> {/* <--------- CLIENT */}
<ServerCounterComponent /> {/* <--------- SERVER */}
</InnerClientComponent>
</SmallServerComponent>
{/* ... and then we can control where to put SDFComponent in layout */}
</ClientComponent>
</RootServerComponent>
If you prefer not to manage this composition at the root level and aim for modularity, you can achieve this by enhancing your ClientComponent
to accept additional propertiesâfor example, a renderProp
. You can then trust that it will include the necessary representation, allowing you to construct your layout in any preferred manner.
Personally, I consider the Render Props
pattern to be one of React's most powerful features.
The Final Hurdle
The very last âchallengeâ involved transitioning from the useSearchParams
hook provided by react-router
to its counterpart in next.js
. The twist with Next.js is that while it lets you read the search parameters, it doesnât offer a direct setter. This requires a more hands-on approach involving a trio of hooks: usePathname
, useRouter
, and useSearchParams
. To manage this, you have to construct your own setter. The process would typically look something like this:
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
// later in code
const params = new URLSearchParams(searchParams?.toString() || "");
if (field) {
params.set(field, fieldValue);
params.set(field, fieldValue);
} else {
params.delete(field)
}
router.replace(pathname + "?" + params.toString(), { scroll: false });
We moved this into a convenient hook, so we could reuse the code and update multiple parameters simultaneouslyâa necessity for managing the filters in our table.
Cherries on top đđ°
Leveraging Next.js, a server-side solution, made setting OpenGraph metadata, titles, and descriptions for our pages simply a breeze. This final requirement was almost effortlessly fulfilled. However, we encountered a hiccup with @vercel/og
, which refused to render fonts properly, even the bold
variant of the default one.
After some troubleshooting, we discovered that Noto Sans, included in the package, only supports a 400 weight. Attempts to load custom fonts from the file system did not pan out as expectedâthe documentation and examples lacked clear guidance on implementation. Furthermore, deployment introduced peculiar bugs, even though the local solutions worked just fine.
Despite these challenges, experimenting with Satori (the library that lets you create SVG/PNG images by defining them with HTML) proved to be quite enjoyable. We would definitely consider using this package in future projects.
Additionally, caching was readily available right out of the box, allowing us to lessen the load on our servers and enhance the overall user experience.
Postmortem
Aside from the initial confusion surrounding server components, the majority of the migration process unfolded smoothly.
The groundwork established during the SPA implementation proved to be robust and resilient, carrying us through the migration with only minimal adjustments needed.
Most of our headaches stemmed from styled-components
. Despite the merits of this CSS-in-JS library, it doesnât mesh as seamlessly with server components. Traditional CSS methodologies, such as BEM (Block, Element, Modifier), CSS modules, or even utility-first frameworks like Tailwind, would have alleviated many of our difficulties. This lesson is a key takeaway that we plan to carry forward into our future projects.
Top comments (0)