DEV Community

Max Daunarovich for FindLabs

Posted on

Darling, I converted our perfectly fine SPA application into SSR: Part 2

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
  │ ...

Enter fullscreen mode Exit fullscreen mode

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:

Image description

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:

Image description

Image description

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.

Noice

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:

Image description

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:

Image description

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>
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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)