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.
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 the following 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 detectedlocale
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 thei18n
context. (For more information on holis i18n solution, see Internationalization)Feature flags
The current state of feature flags for the current user (configured in Posthog, see Tracking) is fetched and passed on via props to be used as initial state on the client side as well.
User state
We fetch the current user to determine the login state and pass on information about the success as page props to speed up and synchronize intialization of the login state on the client side.
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.
We currently do not have a specific error page for "Permission denied" (status code 403), but display the "Not Found" page instead.
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.
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.
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.
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 errorsA query to fetch the currently user if logged in is always executed and does not have to be added manually.
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.
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)
}
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.
const ExampleScreen = () => {
const { loading, data } = useQuery(exampleQuery)
if (loading && !data) { // data might already be present in the cache!
return <LoadingScreen />
}
return <ExampleContent data={data} />
}
The helper function createServerSideProps
always fetches the current user if logged in to pre-warm the cache and add page props about the login state. The page props are used to synchronize the inital login state between web server and client. The hook useLoggedInUser
then also checks the cache for an already loaded user and should be used whenever a SSR relevant component requires the login state or user object. It also ensures to load the user properly on mobile or web pages that do not support SSR.
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.
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.
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 introduced for this case.
<SuppressSsrHydrationWarning>
{new Date().toLocaleString()}
</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.
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.