Skip to main content

Server-side rendering (SSR)

This document describes holis approach to server-side rendering and lists some guidelines for implementation and common pitfalls.

Motivation

The main reason to implement SSR was the requirement to directly answer requests with HTML that is enriched with meta tags for the following use cases:

  • SEO: meta tags for search results (title, description etc), possibly also structured data following schema.org (author, date etc.). For more information see SEO meta tags.
  • Social media previews: OpenGraph meta tags

SSR can also bring performance improvements on web, as it reduces the perceived loading time for users.

However, this does not provide any advantages to the mobile app. As the frontend code is shared for both the web and the mobile app it is necessary to ensure that all screens still support pure client-side rendering.

Basic principles

Server-side rendering with React

The HTML generated on the server side will not be interactive until JavaScript is loaded and executed on the client-side as well. This process is called "hydration" in React (see hydrateRoot): React will calculate the DOM on the client side as well (similar to client-side rendering) and attach all event listeners and other logic to the already existing DOM elements.

caution

For this to work properly, it is necessary that the HTML generated on server and client-side match as closely as possible. Otherwise React will throw errors and switch to client-side rendering.

In some cases this even lead to duplicated HTML where the server-side HTML was not replaced but only appended by client-side HTML.

As all required data can already be fetched on the server-side, it is possible to skip loading states on the client-side, reducing the aforementionend perceived loading time.

Server-side rendering with Next.js

In order to achieve equality between the HTML generated on both server and client side, Next.js supports adding props to the server response that can be used on the client side as well. This is done by implementing getServerSideProps for each page that should support SSR.

During server-side rendering two types of props are added to the server response:

  • i18n locale and translations

    The requests locale is determined by Next.js (locale subpath or accept-language header). The detected locale as well as all translations for this locale are added to the props provided to the client, so that it can be used there for initializing the i18n context. (For more information on holis i18n solution, see Internationalization)

  • Feature flags

    The current state of feature flags (configured in Posthog, see Tracking) is fetched and passed on via props to be used as initial state on the client side as well.

  • Apollo cache

    When the page is fully rendered on the server side i.e. all relevant queries were executed, the query cache on the server side contains all data that would also be needed to render the same page on client side. The server cache is serialized and added to the page props, and deserialized on the client-side to initialize the client-side Apollo cache. This is basically "prewarming" the cache, because whenever a query is executed now, the client can access the results that were prefetched on the server side (as long as the fetchPolicy is reading from the cache).

    When it is detected that the requested content could not be found or the user does not have the necessary permission to view the content, the server answers with a "Not found" response displaying the 404 page and returning the appropriate status code.

info

We currently do not have a specific error page for "Permission denied" (status code 403), but display the "Not Found" page instead.

note

Currently the server side props are only added on the first request, but not on subsequent navigation, as this would load duplicated or unnecessary data and slow down the user experience.

info

Next.js also allows to implement getStaticProps, which does not get called per request, but only once during build time, i.e. it could only be used for completely static pages.

info

Currently holi is using Next.js with the Pages Router. With version 13 Next.js introduced a new router approach called App Router with concepts like Server Components or Streaming. However, it would require further investigation to find out if this model is compatible with React Native.

info

Holis SSR implementation does not strictly follow the recommended approach by Apollo. Apollo suggests to use getDataFromTree to collect all queries for the page to render. However during implementation it became apparent this does not readily support sequential queries (i.e. cases were one query depends on the results of another) and error cases like 404 were not handled properly. Instead an official example for Pages Router using Apollo by Next.js was combined with direct application of the mechanisms used inside Apollos getDataFromTree inspired by a Next.js discussion, so that it is not necessary to manually list all required queries.

Implementation

Creating server side props

In general, implementing the function getServerSideProps activates SSR for a page. To facilitate this process and to achieve the behaviour described above the helper function createServerSideProps was created. This function allows the following optional arguments:

  • queries: Queries to be executed in advance to simplify or speed up the page rendering process (e.g. when queries are executed sequentially one after another) or handle 404/403 errors
  • customProps: Custom SEO props like (invisible) h1 header or localized path names (see SEO H1 Heading)

On pages with query parameters the usage of useQuery(<param>) can be reproduced by accessing context.query.<param> where context is the request context provided by Next.js.

Example

Given the following "Example screen"

const ExampleScreen = () => {
const [exampleId] = useParam('exampleId')

const { loading, data } = useQuery(exampleQuery, {
variables: {
id: exampleId,
}
})

//...
}

SSR can be enabled by exporting getServerSideProps inside apps/web/pages/example/[exampleId].tsx:

const ExamplePage: NextPageWithLayout = () => <ExampleScreen />

export const getServerSideProps = createServerSideProps

export default ExamplePage

Custom SEO props:

export const getServerSideProps = async (context: NextPageContext) =>
createServerSideProps(context, [], {
seoTitle: 'seo.h1.exampleScreen',
urls: {
en: '/example',
de: '/beispiel',
},
})

Pre-execute queries to handle errors like 404 or speed up sequential queries:

export const getServerSideProps = async (context: NextPageContext) => {
const { exampleId } = context.query

const queries = [
{
query: exampleQuery,
variables: {
id: exampleId,
},
},
]

// Queries defined above are executed inside `createServerSideProps`, which
// takes care of error handling
return createServerSideProps(context, queries)
}
caution

The client-side Apollo client is only able to use the cache results if the variables (or rather the resulting cache key) exactly match the ones used on the server side.

Cache usage on the client side

To prevent errors during hydration and shorten the perceived loading time for users, the client should use the cache state that was "prewarmed" during SSR. When using a query like const { loading, data } = useQuery(...) the data might already be present during the initial render, so might not always be necessary to display a loading state if loading is true.

caution

The SSR implementation currently assumes logged-out users for all queries, as the main reason was to support SEO and other meta tags. If a query is expected to return differing data for a logged-in user (e.g. a space member), the data should be refreshed on the client side, e.g. by setting the fetchPolicy to cache-and-network or something similar.

Example
const ExampleScreen = () => {
const { loading, data } = useQuery(loginDependingQuery, {
fetchPolicy: isSSR ? 'cache-first' : 'cache-and-network',
})

if (loading && !data) { // data might already be present in the cache!
return <LoadingScreen />
}

return <ExampleContent data={data} />
}

Handling differences between server and client

As there is no browser context during server-side rendering and some content or logic might only be needed on the client side, it is sometimes necessary to differentiate between server and client in components to prevent hydration errors.

Client-only logic

All logic that should only be executed on the client side should be wrapped inside a useEffect as such effects do not get executed during SSR.

Web APIs

There is no window, document or similar objects available during SSR or any other web API that might be used. The helper isSSR can be checked in such cases to prevent errors. Another option would be to move the logic into a useEffect.

Window dimensions, pixel density, mobile safe areas etc.

Instead of useWindowDimensions the hook useClientWindowDimensions should be used which ensures the provision of default values during SSR and a rerender with the correct values on the client side.

However, the rerender might cause a styling flash for the user. Some of these issues can be prevented by using media queries (also working for React Native with react-native-media-query) or the HoliContainer component.

Similarly the the SafeAreaProvider defined in the component library ensures proper default values during SSR.

Example
import StyleSheet from 'react-native-media-query'

const { styles, ids } = StyleSheet.create({
container: {
[`@media (min-width: ${breakpointS}px)`]: {
paddingLeft: 32,
},
[`@media (min-width: ${breakpointL}px)`]: {
paddingLeft: 64,
},
},
})

const Component = () => {
return (
<View
style={styles.container}
dataSet={{ media: ids.container }}
>
{/* ... */}
</View>
)
}

Layout effects

Usages of useLayoutEffect will result in warnings during SSR and should be replaced by useEffect instead, or useIsomorphicLayoutEffect if the behaviour should be kept on mobile.

Access restrictions

Pages or components that should only be visible to logged-in users or require specific permissions should be wrapped inside a PermissionsGuard. This will prevent rendering the component during SSR and check for the permissions on the client side.

Example
<PermissionsGuard permissionType={PermissionsType.AUTH}>
<LoggedInOnlyComponent />
</PermissionsGuard>

Suppressing warnings

In very limited use-cases it is possible to suppress a hydration warning by setting the prop suppressHydrationWarning:

  • Only works for HTML tags (e.g. div, span etc.)
  • Only prevents warnings one level deep
  • Only works for differences in text content

A typical use case would be formatted timestamps, as the timezone for server and client might differ.

The helper component SuppressSsrHydrationWarning only accepting child components of type string was intruduced for this case.

Example
<SuppressSsrHydrationWarning>
{isLoggedIn ? 'Logged in' : 'Logged out'}
</SuppressSsrHydrationWarning>

Disabling SSR

Sometimes it's necessary to fully disable SSR for a page and enforce client-side rendering to prevent errors. This can be achieved by using dynamic imports.

Example
import dynamic from 'next/dynamic'

const ClientOnlyScreen = dynamic(() => import('<path-to-screen>'), { ssr: false })

const ClientOnlyPage: NextPage = () => <ClientOnlyScreen />

export default ClientOnlyPage

Testing

Ensuring that the result of SSR is correct and hydration works properly, there is no way around a certain amount of manual testing:

  • All pages should be tested in a browser by refreshing the page and checking the logs (browser and web server)!
  • The server generated HTML can be tested by disabling JavaScript
  • Mobile should be tested as well to ensure everything still works with pure client-side rendering

Testing the contents of server generated HTML can also be automated in web e2e tests by creating a browser context with disabled JavaScript.