Loading...

Or rather "classic approach to SSR in a universal app" isn't.

Ok, let's start from setting up a common vocabulary. Server-side rendering or SSR is, technically, any rendering that occurs on a server. Including that old-shool backend template-based rendering. In 2020-s, however, we're mostly interested in Server-side rendering for SPAs (Single Page Applications).

SSR, for example, is a feature of React, Vue, Angular and other mainstream frameworks. Sometimes, an app with SSR is called an isomorphic or universal. Both terms can be argued as flawed but I'll stick to "universal" as one widely used.

Server-side rendering of a universal app can be full or partial. A distinction that is very important but rarely (if ever) discussed. In the title of this article by "SSR" I mean "Full SSR of a universal web app". You can call it click-bait but that wasn't my goal. Full sentence would make for an awkward title.

Which SSR is full and which is partial? When you should prefer one over another? And why should you care? Now, as we're agreed on the terms, let's get to the actual content!


First, let's assume we have a site and some content on that site is user-generated. User-generated data is not available at build time so it's a good context to discuss and evaluate different techniques of presenting the data to the site visitors.

We have, at least, four distinct approaches to consider (not counting their hybrids).

1. Old-school Server-side Rendering

I won't waste our time by revisiting the basics. My readers are mostly practicing programmers and you, hopefully, don't need this kind of information. This approach was used, for example, in PHP before the advent of SPAs. It implies no API & Client separation and is completely obsolete since 2000s. I'm not saying you should "Drop everything and go rewrite your old PHP-based site". If it works – don't break it. I'm just saying that you probably won't rely on this approach for the modern projects.

2. Static Generator (classic)

Good old "just export to HTML" approach. Again, too obvious to discuss. It's not compatible with user-generated content so I'm mentioning it just for the sake of formality.

Pros

The best performance. Easy app code (if any).

Cons

Eh, dynamics?

3. Static Generator + Triggered Rebuilds

An approach that is hardly ever mentioned. You can technically trigger a rebuild of your site as a new piece of content is added. This can be useful in rare cases where you have a small team of content creators and data that is updated occasionally.

I used a similar approach in one of my older projects: pre-cached some frequently required data in server RAM, restarted a server each time those data were updated. It worked nice and brought great performance benefits at the cost of extra risks (a server could potentially not start after a content update).

Pros

No communication with dev. team required to see new content on the site.

Cons

Not scalable in most cases for obvious reasons (security, performance, etc) Can be seen as a clever hack but a hack nevertheless.

3. Static Generator (modern)

Static build-time server-side + Dynamic client-side rendering. The approach popularized by Gatsby, Phenomic and other modern static generators. This combination makes modern static generators extremely powerful and capable. The one piece of the puzzle that is still missing though is the same: user-generated data is not available at build time.

You can fetch new items dynamically on the client. You can't hovewer, do that on the server as build occurs before the item was created. Therefore you can't inject a proper title of that brand-new item in your HTML.

For some reason, this aspected is not represented at all in Gatsby vs NextJS comparison table which, if you ask me, feels like a marketing treak to put Gatsby over a competitor. Claim the points where you win, skip the opposite ones – kinda cheap.

NextJS is objectively a more powerful tool. It has a dynamic SSR, unlike Gatsby which can be negligible or game-changing, depending on your project and business specifics.

4. Full SSR (classic)

We're getting to the meat of the article.

With (full) SSR pages are fully rendered dynamically per request on a server. The same pages are rehydrated and possibly rerendered on the client. As soon as the DOM & BOM are ready and your frontend app takes full control over the page. All user-generated stuff can be immediately used because the both the server and the client are fully dynamic. There's no build step which would "freeze the data". This is the approach which comes to mind when someone uses the "SSR" term.

My goal is to make you realize it's not the only possible way to do SSR in universal apps.

Pros

Turnkey SEO.

Cons

(Full) SSR is complex to develop and support in code. And when I say "complex" I really mean it. The nature of its complexity is such that it can't be removed from your code and isolated in library. It's an unusual thing and we can see SSR as a paradigm of development, not just app's feature.


To prove that full SSR is seen as the default (if not the only) way to do SSR I can redirect you to the docs of modern tools. I've already mentioned React and Vue. You can also check Apollo-Client or URL or anything else you prefer. Most likely, it will be based on the same idea.

With full SSR you have to think twice as hard as you develop your code and architecture, and as you upgrade your dependencies. Frow now on I will say "SSR" instead of a "Full SSR for an SPA" phrase for the sake of convenience.

From an outside perspective, SSR is a rendering "done twice". Once on a server, once in a browser. That, alone, forces you to constantly think of two possible runtime scenarios in your client code. "Suddenly" something starts to behave differently between server and client renders. "Suddenly" you find yourself spending a frustrating amount of time to troubleshoot that. "Suddenly" you find yourself going through that process more and more often.

The reality, hovewer, is harsher.

In GraphQL clients, for example, you'll need not two, but three code "lines". Yes, three! Check the main READMEs of the official NextJS examples for more details. The ones that have apollo in their names.

To save you time, the full process of rendering with Apollo-Client & NextJS (that is officially recommended) can be summarized like this:

  1. Render the component tree to collect GraphQL queries on server. Remember that your nested components also may trigger data fetching (unlike basic NextJS approach with getInitialProps singleton fetching). This phase invokes data fetching and occurs in a context of empty "client" caches.

  2. Render the component tree with data on server. This phase does not invoke data fetching and occur in a context of filled "client" caches.

  3. The caches will be serialized to JSON and injected in HTML by NextJS. This happens in the library code.

  4. The caches will be restored on the client side and made accessible for React hooks. The line of code with new ApolloClient(...).restore(initialState) are responsible for that.

  5. Hydrate the component tree in browser. This phase does not invoke data fetching for the first page and occur in a completely new environment. For some scenarios you caches will be filled and for some – won't.

Take a look at the following "starter" code copied from official NextJS examples and slightly shortened:

import React from "react"
import Head from "next/head"
import {ApolloProvider} from "@apollo/react-hooks"
import {ApolloClient} from "apollo-client"
import {InMemoryCache} from "apollo-cache-inmemory"
import fetch from "cross-fetch"

let globalApolloClient = null

export function withApollo(PageComponent, {ssr = true} = {}) {
  let WithApollo = ({apolloClient, apolloState, ...pageProps}) => {
    let client = apolloClient || initApolloClient(undefined, apolloState)
    return (
      <ApolloProvider client={client}>
        <PageComponent {...pageProps} />
      </ApolloProvider>
    )
  }

  if (ssr || PageComponent.getInitialProps) {
    WithApollo.getInitialProps = async ctx => {
      let {AppTree} = ctx

      // Initialize ApolloClient, add it to the ctx object so
      // we can use it in `PageComponent.getInitialProp`.
      let apolloClient = (ctx.apolloClient = initApolloClient({
        res: ctx.res,
        req: ctx.req,
      }))

      // Run wrapped getInitialProps methods
      let pageProps = {}
      if (PageComponent.getInitialProps) {
        pageProps = await PageComponent.getInitialProps(ctx)
      }

      // Only on a server:
      if (typeof window == "undefined") {
        // When redirecting, the response is finished.
        // No point in continuing to render
        if (ctx.res && ctx.res.finished) {
          return pageProps
        }

        // Only if ssr is enabled
        if (ssr) {
          try {
            // Run all GraphQL queries
            let {getDataFromTree} = await import("@apollo/react-ssr")
            await getDataFromTree(
              <AppTree
                pageProps={{...pageProps, apolloClient}}
              />
            )
          } catch (error) {
            // Prevent Apollo Client GraphQL errors from crashing SSR.
            // Handle them in components via the data.error prop:
            // https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error
            console.error("Error while running `getDataFromTree`", error)
          }

          // getDataFromTree does not call componentWillUnmount
          // head side effect therefore need to be cleared manually
          Head.rewind()
        }
      }

      // Extract query data from the Apollo store
      let apolloState = apolloClient.cache.extract()

      return {
        ...pageProps,
        apolloState,
      }
    }
  }

  return WithApollo
}

function initApolloClient(ctx, initialState) {
  // Make sure to create a new client for every server-side request so that data
  // isn't shared between connections (which would be bad)
  if (typeof window == "undefined") {
    return createApolloClient(ctx, initialState)
  }

  // Reuse client on the client-side
  if (!globalApolloClient) {
    globalApolloClient = createApolloClient(ctx, initialState)
  }

  return globalApolloClient
}

function createApolloClient(ctx = {}, initialState = {}) {
  let ssrMode = typeof window == "undefined"
  let cache = new InMemoryCache().restore(initialState)

  return new ApolloClient({
    connectToDevTools: !ssrMode,
    ssrMode,
    link: createIsomorphLink(ctx),
    cache,
  })
}

function createIsomorphLink(ctx) {
  if (typeof window == "undefined") {
    let {SchemaLink} = require("apollo-link-schema")
    let {schema} = require("./schema")
    // Doesn't support context resolvers...
    return new SchemaLink({schema, context: ctx})
    // It will be even more complex @_@
  } else {
    let {HttpLink} = require("apollo-link-http")
    return new HttpLink({
      uri: "/api/graphql",
      fetch: fetch,
    })
  }
}

Starting to see what I was telling you about the complexity?

Long story short – Full SSR with Apollo-Client (Relay, URQL, any GraphQL client really) brings with itself a substantial and unremovable complexity that contaminates your entire client layer.

Your "view" code is now invoked on two different platforms, in three different modes. Yes, even on server you have two execution lines with slightly different global variables and different cache states. Are you already imagining how "exciting" your development and debug process will become with this approach? How much more corner cases and gotchas will you met and spend nights thinking of.

Consider just this – each time you have a client-side cache used on server and there's some memory leak or bug... It won't just affect clients but your servers as well!

Are you sure that all your "isomorphic dependencies" are free of such bugs and conditions? Right now and constantly in future? Are you ready to debug cross-platform race conditions?

I confess – I'm not. Hence my decision to gradually move away from (classic) SSR. When people say "SSR killed are project" they are saying about the classic full SSR. Luckily, it's not the only approach that is possible.

5. Partial SSR (modern)

The problem with SSR is that it was invented, first of all, to satisfy SEO needs. Performance aspect was always on the "possible" side of benefits. Many (professional) developers reported they got overall worse performance benchmarks with SSR. It's faster to render an app on a powerful machine (server) than on a week (mobile client). But the server is a single machine or a cluster at best. Client-side rendering is a huge parallelism for free. So, performance-wise a lot of things depend on a particular project, its users, etc.

Now consider that the value of SEO and "being indexable by Google" reduces each year. Google is not the main source of traffic for most modern online resources. Not anymore. As a company, Google wants you to endlessly pay for their ads. The SERP is already occupied with your competitors. The "per-click" prices are steep and conversions are miserable in comparison to social networks. SEO is dying and so are search engines. We're in the era of "Social Web" already.

What's also valuable in SSR and rarely brought up, hovewer, is the presence of meta-data in HTML. First of all – page titles. Without them, most social networks will display broken links to your site (because of basic CURL-like data fetchers). There are some recent improvements in this area. I've heard, for example, that Slack now supports browser-based page prefeching which works with SSR-less SPAs.

So SSR allows us to fill that meta-data dynamically. But do we really need to render the whole view layer for that? Meta-data usually sits in the <head> section of HTML. As we want a dynamic data, we will fetch it – from some API. We can't just hardcode <title>My Post</title> in some layout because those titles are stored in our database and there's a single layout for multiple posts, most probably.

Nevertheless, we can ponder on this idea and realize that we can switch an approach to rendering.

In classic SSR we have to wait for all data fetching to finish (and probably rerender a whole view layer) before we send an HTML to the browser. In this imaginary model we can send a part of the page with some rendered metas and loading indicators.

As it often happens – I sticked to full SSR for years before I realized I don't really like it and the alternative of partial SSR is possible. Full SSR was and remains something the authoritative sources recommend. I'll be honest, I gradually started to hate web development because of SSR and all the complexity it brings.

Before we could fully enjoy the long-awaited separation of presentation and logic layers they collapsed on each other back again! Imagine my releaf when I realized the "offical SSR" is not the one and not the best way to do SSR.

Ever noticed how YouTube page is loaded?

YouTube Loading Screen

It's not a complete SSR but not an "empty HTML" either. The initial page payload contains some visual placeholders to make you think it's loading faster than it actually is. But it's not only a UX trick. By getting rid of SSR we can reap multiple benefits: much simpler code base, much faster TTFB, much lower server load, etc.

Now let's check the sources of Elm docs. This language notoriously lacks SSR and how do you think they reduce the bad consequences of that? By injective some meta data in HTML:

Elm Docs Sources

See what I'm telling you? It's possible, it's already used and its just those "SSR docs" that stuck in our minds.

Put a head title, maybe some metas. Render the Loading indicators. Send that as an initial HTML. Let the client do the rest.

In the same spirit, you can even inject some body paragraphs to address the readability of your side by crawlers. Yes, it will make user and crawler experience different but Google always commented "it's always about your intent" and we don't trick crawlers here.

Back to our comparison of Partial SSR features:

Pros

The best performance. 90+ Lighthouse Score becomes achievable for modern apps again. Great UX. Drastically leaner and more maintainable code.

Cons

Potential issues with classic SEO.


I've made a sandbox project you can inspect and play with. It's a very simple demo of partial SSR in NextJS. I did nothing special – all the work to make it possible is already done by amazing engineers at Zeit.

Oh, the last thing! How partial SSR will simplify the above code? All that "dark magic" boilerplate can be reduced to something like:

import React from "react"
import {ApolloProvider} from "@apollo/react-hooks"
import {ApolloClient} from "apollo-client"
import {InMemoryCache} from "apollo-cache-inmemory"

export function withApollo(PageComponent,) {
  let WithApollo = ({apolloClient, ...pageProps}) => {
    let client = apolloClient || initApolloClient()
    return <ApolloProvider client={client}>
      <PageComponent {...pageProps} />
    </ApolloProvider>
  }
  return WithApollo
}

function initApolloClient(ctx) {
  // Create a new client per request on server
  if (typeof window == "undefined") {
    return createApolloClient(ctx)
  }

  // Reuse the same client in browser
  if (!globalApolloClient) {
    globalApolloClient = createApolloClient(ctx)
  }

  return globalApolloClient
}

function createApolloClient(ctx = {}) {
  let ssr = typeof window == "undefined"

  let defaultOptions = {
    query: {
      fetchPolicy: ssr ? "no-cache" : "cache-first",
      errorPolicy: "all",
    }
  }

  let cache = new InMemoryCache() // Notice no `.restore(..)` here

  return new ApolloClient({
    connectToDevTools: !ssr,
    ssrMode: ssr,
    // TODO some NoopLink for the server to avoid wasteful API requests
    link: new HttpLink({
      uri: "/api/graphql",
      fetch: fetch,
    }),
    cache,
  })
}

Yeah!

Conclusion

You have to clearly understand the architectural limitations of each approach to choose the most appropriate and use it to its full power. I'm personally excited about switching from full SSR to partial. How often do we get a chance to make code much easier and more performant at the same time?!

I think it's a shame that Apollo and other vendors recommend such an overcomplicated approach to SSR when an alternative, that can be argued as objectively better, exists. I'm working on the alternative example for NextJS + Apollo stack combo.

It's worth mentioning that the recent updates of NextJS allow it to compete with Gatsby on the field of static generators as well. I've never regretted my choice of NextJS and you probably won't either. Apollo stacjk is awesome as well. Just give them a try, if you didn't.

Thank you for your time. Bye!