Server-side rendering for any React app on any FaaS provider

烏柏木發表於2023-04-02

By this document, I'd like to introduce a general method to set up server-side rendering(SSR) for any React app on any FaaS provider. A "React app" is a web app with its client side (or frontend) built with React. A "FaaS provider" is a serverless computing platform, such as AWS Lambda. To state the idea clearly, a runnable demo app is constructed step by step below. I would guide you through the steps, then summarize the idea.

Thinking that the demo app should be practical but not overwelmed by details, its React client side would be constructed with commonly seen features, such as styling, routing, data fetching and assets loading, but at a limited cost. Meanwhile, it would be deployed on a widely accepted FaaS provider that has an easy setup. So, I'm going to use create-react-app(CRA) to initialize the demo app, enhance it, then get it deployed on Netlify with SSR added.

The demo app

Constructing the React client side without SSR

Firstly, let me initialize the React client side of the demo app using CRA. As TypeScript is being commonly used in today's frontend development, the option --template typescript is used here:

$ npx create-react-app the-demo-app --template typescript
# ...
$ cd the-demo-app

The version of CRA in use is 5.0.1 and the generated directory structure looks as below:

$ tree -I node_modules
.
├── README.md
├── package-lock.json
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.css
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── index.css
│   ├── index.tsx
│   ├── logo.svg
│   ├── react-app-env.d.ts
│   ├── reportWebVitals.ts
│   └── setupTests.ts
└── tsconfig.json

2 directories, 19 files

Among the generated files, src/App.css is importing and using src/App.css and src/logo.svg, which means features of styling and assets loading has been included:

// src/App.tsx
import './App.css';
import logo from './logo.svg';

function App() {
  return (
    <div className="App">
      ...
      <img src={logo} className="App-logo" alt="logo" />
      ...
    </div>
  );
}

export default App;

Now, to ensure the commonly seen features are all set, routing and data fetching are to be included, too. For the routing, react-router-dom, de facto routing lib in React, would be used:

$ npm i react-router-dom

To get the routing to work at a minimum cost, 2 pages and the routing logics that can switch them are needed. The content of src/App.tsx can be replaced with the routing logics while the old content gets moved to src/pages/Home/Page.tsx serving as one needed page. Then, a not-found page src/pages/NotFound/Page.tsx can be added serving as the other needed page. When the path / is visited, the former page gets shown. When any other path is visited, the latter page gets shown:

// src/App.tsx
import { FC } from 'react';
import { Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/Home/Page';
import { NotFoundPage } from './pages/NotFound/Page';

export const App: FC = () => {
  return (
    <Routes>
      <Route path="/" element={<HomePage />} />
      <Route path="*" element={<NotFoundPage />} />
    </Routes>
  );
};
// src/App.test.tsx
import { render, screen } from '@testing-library/react';
import { StaticRouter } from 'react-router-dom/server';
import { App } from './App';

jest.mock('./pages/Home/Page', () => ({
  HomePage: () => 'Home Page',
}));

jest.mock('./pages/NotFound/Page', () => ({
  NotFoundPage: () => 'Not Found Page',
}));

for (const [path, page] of Object.entries({
  '/': 'Home Page',
  '/somewhere-else': 'Not Found Page',
})) {
  test(`renders "${page}" if "${path}" is visited`, () => {
    render(
      <StaticRouter location={path}>
        <App />
      </StaticRouter>
    );
    expect(screen.getByText(page)).toBeInTheDocument();
  });
}
// src/pages/Home/Page.tsx
import { FC } from 'react';
import logo from '../../logo.svg';
import styles from './Page.module.css';

export const HomePage: FC = () => {
  return (
    <div className={styles.root}>
      <header className={styles.header}>
        <img src={logo} className={styles.logo} alt="logo" />
        <p>
          Edit <code>src/**/*.tsx</code> and save to reload.
        </p>
        <a
          className={styles.link}
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          Learn React
        </a>
      </header>
    </div>
  );
};
/* src/pages/Home/Page.module.css */
.root {
  text-align: center;
}

.logo {
  height: 40vmin;
  pointer-events: none;
}

@media (prefers-reduced-motion: no-preference) {
  .logo {
    animation: logo-spin infinite 20s linear;
  }
}

.header {
  background-color: #282c34;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  font-size: calc(10px + 2vmin);
  color: white;
}

.link {
  color: #61dafb;
}

@keyframes logo-spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}
// src/pages/NotFound/Page.tsx
import { FC } from 'react';
import styles from './Page.module.css';

export const NotFoundPage: FC = () => {
  return (
    <div className={styles.root}>
      <h1>Not Found</h1>
    </div>
  );
};
/* src/pages/NotFound/Page.tsx */
.root {
  text-align: center;
}

After that, to make the routing logics take effect, <BrowserRouter> is applied in src/index.tsx:

// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

For the data fetching, 2 other libs, @tanstack/react-query(whose old package name is react-query) and axios, are used:

$ npm i @tanstack/react-query axios

The data fetching logics are, the Home/Page.tsx invokes a query function that fetches the star count of a GitHub repo, then gets the result rendered:

// src/pages/Home/Page.tsx
-import { FC } from 'react';
+import { FC, useMemo } from 'react';
import logo from '../../logo.svg';
+import { ParamsOfGetRepoStarCount, useGhRepoStarCountQuery } from '../../queries/gh';
import styles from './Page.module.css';

+export const paramOfGhRepoStarCountQuery: ParamsOfGetRepoStarCount = {
+  userName: 'facebook',
+  repoName: 'react',
+};

export const HomePage: FC = () => {
+  const numberFormat = useMemo(() => new Intl.NumberFormat(), []);
+  const { isLoading, isSuccess, data } = useGhRepoStarCountQuery(paramOfGhRepoStarCountQuery);
+
  return (
    <div className={styles.root}>
      <header className={styles.header}>
        <img src={logo} className={styles.logo} alt="logo" />
        <p>
          Edit <code>src/**/*.tsx</code> and save to reload.
        </p>
        <a
          className={styles.link}
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
-          Learn React
+          Learn React (⭐️ = {isLoading && 'loading...'}
+          {isSuccess && numberFormat.format(data.result)})
        </a>
      </header>
    </div>
  );
};

And here is the query function in src/queries/gh.ts:

// src/queries/gh.ts
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';

export type ParamsOfGetRepoStarCount = Partial<{ userName: string; repoName: string }>;

export type ReturnOfGetRepoStarCount = { result: number };

export function getKeyOfGhRepoStarCountQuery(params: ParamsOfGetRepoStarCount) {
  return ['ghRepoStarCountQuery', params];
}

export function useGhRepoStarCountQuery(params: ParamsOfGetRepoStarCount) {
  return useQuery(getKeyOfGhRepoStarCountQuery(params), async () => {
    const { data: repoInfo } = await axios.get(
      `https://api.github.com/repos/${params.userName}/${params.repoName}`
    );
    return { result: repoInfo.stargazers_count } as ReturnOfGetRepoStarCount;
  });
}

As well as its base setup:

// src/queries/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export function createQueryClient(): QueryClient {
  return new QueryClient();
}
// src/index.tsx
+ import { QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
+import { createQueryClient } from './queries/queryClient';
import reportWebVitals from './reportWebVitals';

+const queryClient = createQueryClient();
+
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode>
-    <BrowserRouter>
-      <App />
-    </BrowserRouter>
+    <QueryClientProvider client={queryClient}>
+      <BrowserRouter>
+        <App />
+      </BrowserRouter>
+    </QueryClientProvider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

By far, the client side of the demo app with commonly seen features of a React app is constructed. You may check it by running scripts from package.json.:

// package.json
  ...
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
  ...

The commands are npm start for previewing, npm run build for building, npm test for unit-testing. In some versions of CRA, with the features above, pages can't get refreshed automatically on edited in the previewing. If it is your case, setting FAST_REFRESH=false will help:

$ npm i cross-env
// package.json
  ...
  "scripts": {
-    "start": "react-scripts start",
+    "start": "cross-env FAST_REFRESH=false react-scripts start",
  ...

Adding SSR and deploying the app on Netlify

On diving into the SSR and the deployment on Netlify, let's take a look at how the essential logics of SSR in a React app work:

  1. When a request for a page comes, the app's server side prepares the data that the requested page would request on the client side. Then, the data together with the root client-side React component are used to generate the server-side rendered html of the requested page. With the data serialized, the server side inserts it along with the server-side rendered html into the top-level html to make the final html as the response.
  2. When the response of the requested page arrives, the app's client side deserializes the serialized data from the response, then uses it together with the root client-side React component to hydrate the server-side rendered html from the response to initialize the React client side.

So, in the first place, a server side that is able to import and use src/App.tsx (which is the root client-side React component here) should be set up. Also, as server-side logics on Netlify are primarily done by Netlify Functions, the convention of registering functions by files paths should be followed.

To use types in Netlify Functions, @netlify/functions is installed:

$ npm i @netlify/functions

Then, the Netlify function for SSR is created in src/server/functions/render_pages.tsx with a placeholder string returned for now:

// src/server/functions/render_pages.tsx
import { Handler } from '@netlify/functions';

export const handler: Handler = async (event, context) => {
  return { statusCode: 200, body: 'It works!' };
};

And the client-side webpack config is as much as possibly reused to create a server-side webpack config. It is adjusted for transpiling the Netlify functions files as well as the files imported by them, preserving the directory structure and stopping assets getting emitted. The server-side webpack config is created in webpack.server.config.js:

// webpack.server.config.js
const glob = require('glob');
const { set } = require('lodash');
const TranspilePlugin = require('transpile-webpack-plugin');
const nodeExternals = require('webpack-node-externals');

const envInQuestion = process.env.NODE_ENV ?? 'development';
const shouldPrintConfig = Boolean(process.env.PRINT_CONFIG);

process.env.NODE_ENV = envInQuestion;
process.env.FAST_REFRESH = 'false';
const webpackConfig = require('react-scripts/config/webpack.config')(envInQuestion);

webpackConfig.entry = () => glob.sync('src/server/**/*', { nodir: true, absolute: true });
webpackConfig.target = 'node';
webpackConfig.externals = [nodeExternals()];

removeAssetsEmitting();
removeUnusedPluginsAndOptimizers();

webpackConfig.plugins.push(
  new TranspilePlugin({
    longestCommonDir: __dirname + '/src',
    extentionMapping: { '.ts': '.js', '.tsx': '.js' },
  })
);

if (shouldPrintConfig) {
  console.dir(webpackConfig, { depth: Infinity });
}

function removeAssetsEmitting() {
  webpackConfig.module.rules.forEach(({ oneOf }) => {
    oneOf?.forEach((rule) => {
      if (rule.type?.startsWith('asset')) {
        set(rule, 'generator.emit', false);
      }

      const fileLoaderUseItem = rule.use?.find(({ loader }) => loader?.includes('file-loader'));
      if (fileLoaderUseItem) {
        set(fileLoaderUseItem, 'options.emitFile', false);
      }

      const cssLoaderUseItemIndex = rule.use?.findIndex(({ loader }) =>
        loader?.includes('css-loader')
      );
      if (cssLoaderUseItemIndex >= 0) {
        const cssLoaderOptionModules = rule.use[cssLoaderUseItemIndex].options?.modules;
        if (cssLoaderOptionModules) {
          cssLoaderOptionModules.exportOnlyLocals = true;
        }
        rule.use = rule.use.slice(cssLoaderUseItemIndex);
      }
    });
  });
}

function removeUnusedPluginsAndOptimizers() {
  webpackConfig.plugins = webpackConfig.plugins.filter((p) => {
    const ctorName = p.constructor.name;
    if (ctorName.includes('Html')) return false;
    if (ctorName.includes('Css')) return false;
    if (ctorName === 'WebpackManifestPlugin') return false;
    if (ctorName === 'DefinePlugin') return false;
    return true;
  });

  webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.filter((m) => {
    const ctorName = m.constructor.name;
    if (ctorName.includes('Css')) return false;
    return true;
  });
}

module.exports = webpackConfig;

Also, the packages used in webpack.server.config.js need to be installed:

$ npm i webpack glob lodash transpile-webpack-plugin webpack-node-externals

The webpack plugin transpile-webpack-plugin collects files directly or indirectly imported by the entry, then gets them compiled and ouputted preserving the directory structure. The webpack helper webpack-node-externals externalizes installed deps to reduce the outputted file count.

After that, to run webpack, webpack CLI is needed:

$ npm i webpack-cli npm-run-all

To run multiple scripts from package.json, npm-run-all is needed:

$ npm i npm-run-all

To launch the previewing of the whole demo app including the client side and the server side, Netlify CLI is needed but installing it globally is good enough:

$ npm i -g netlify-cli

Then, scripts in package.json is extended and netlify.toml is created:

// package.json
...
  "scripts": {
-    "start": "cross-env FAST_REFRESH=false react-scripts start",
+    "start:client": "cross-env BROWSER=none FAST_REFRESH=false react-scripts start",
+    "start:server": "cross-env BUILD_PATH=server webpack -w -c webpack.server.config.js",
+    "start-all": "run-p start:*",
+    "dev": "netlify dev",
...
# netlify.toml
[functions]
directory = "server/server/functions"


[dev]
port = 8888
command = "npm run start-all"
targetPort = 3000

Now, executing the command npm run dev on the local machine launches the previewing, which starts the client-side dev server on port 3000, the server-side transpilation in watch mode and the server-side dev server on port 8888, with the url http://127.0.0.1:8888/ opened on the default browser. The Home/Page.tsx is rendered on the path / visited. The NotFound/Page.tsx is rendered on the path like /zxcv visited. The content It works! is rendered on the path /.netlify/functions/render_pages visited. The initial setup of the demo app's server side is made and the demo app becomes initially previewable as a whole.

Notice that, the previewing generates 2 directories, .netlify and server, which should be untracked by the version control. On the command netlify dev executed, .netlify is automatically added to .gitignore. And server needs to be manually added to .gitignore:

# .gitignore
...
# production
/build
+/server
...

Thinking that, in a real-world web app, the client side usually fetches http endpoints built in its own server side. To emulate that closely, another Netlify function is to be added for returning the star count of a GitHub repo and the client-side query function is to fetch data from it instead of directly from the GitHub endpoint. So, the new Netlify function is created in src/server/functions/gh_repo_star_count.ts and its data accessing logics and types are extracted into src/server/gh.ts which gets reused by the client side and the SSR:

// src/server/functions/gh_repo_star_count.ts
import { Handler } from '@netlify/functions';
import { getGhRepoStarCount } from '../gh/repo';

export const handler: Handler = async (event, context) => {
  return {
    statusCode: 200,
    body: JSON.stringify(await getGhRepoStarCount(event.queryStringParameters ?? {})),
  };
};
// src/server/gh/repo.ts
import axios from 'axios';

export type ParamsOfGetRepoStarCount = Partial<{ userName: string; repoName: string }>;

export type ReturnOfGetRepoStarCount = { result: number };

export async function getGhRepoStarCount(
  params: ParamsOfGetRepoStarCount
): Promise<ReturnOfGetRepoStarCount> {
  if (!params.userName || !params.repoName) throw new Error('Bad params');
  const { data: repoInfo } = await axios.get(
    `https://api.github.com/repos/${params.userName}/${params.repoName}`
  );
  return { result: repoInfo.stargazers_count };
}
// src/queries/gh.ts
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
-
-export type ParamsOfGetRepoStarCount = Partial<{ userName: string; repoName: string }>;
-
-export type ReturnOfGetRepoStarCount = { result: number };
+import type { ParamsOfGetRepoStarCount, ReturnOfGetRepoStarCount } from '../server/gh/repo';

export function getKeyOfGhRepoStarCountQuery(params: ParamsOfGetRepoStarCount) {
  return ['ghRepoStarCountQuery', params];
}

export function useGhRepoStarCountQuery(params: ParamsOfGetRepoStarCount) {
  return useQuery(getKeyOfGhRepoStarCountQuery(params), async () => {
-    const { data: repoInfo } = await axios.get(
-      `https://api.github.com/repos/${params.userName}/${params.repoName}`
-    );
-    return { result: repoInfo.stargazers_count } as ReturnOfGetRepoStarCount;
+    const { data } = await axios.get(
+      '/.netlify/functions/gh_repo_star_count?' + new URLSearchParams(params).toString()
+    );
+    return data as ReturnOfGetRepoStarCount;
  });
}
// src/pages/Home/Page.tsx
import { FC, useMemo } from 'react';
import logo from '../../logo.svg';
-import { ParamsOfGetRepoStarCount, useGhRepoStarCountQuery } from '../../queries/gh';
+import { useGhRepoStarCountQuery } from '../../queries/gh';
+import type { ParamsOfGetRepoStarCount } from '../../server/gh/repo';
import styles from './Page.module.css';
...

Sometimes, a GitHub endpoint may fail because of the rate limit. If it is your case, setting a fallback value or introducing a cache will help:

// src/server/gh/repo.ts
...
+const fallbackResultOfGetRepoStarCount = 12345;
+
export async function getGhRepoStarCount(
  params: ParamsOfGetRepoStarCount
): Promise<ReturnOfGetRepoStarCount> {
  if (!params.userName || !params.repoName) throw new Error('Bad params');
-  const { data: repoInfo } = await axios.get(
-    `https://api.github.com/repos/${params.userName}/${params.repoName}`
-  );
-  return { result: repoInfo.stargazers_count };
+  try {
+    const { data: repoInfo } = await axios.get(
+      `https://api.github.com/repos/${params.userName}/${params.repoName}`
+    );
+    return { result: repoInfo.stargazers_count };
+  } catch {
+    return { result: fallbackResultOfGetRepoStarCount };
+  }
}

In addition, here is an optional patch in case debugging with the client-side dev server is wanted. Declaring the proxy field in package.json pointing at http://127.0.0.1:8888 can help pages in http://127.0.0.1:3000 to fetch the server-side endpoints:

// package.json
...
+  "proxy": "http://127.0.0.1:8888",
...

Next, it's time to do the SSR. Only requests for pages are supposed to be handled, but, limited by available routing options of Netlify, requests that are possibly for pages all need to be redirected to the functions/render_pages.tsx:

# netlify.toml
[functions]
directory = "server/server/functions"


+[[redirects]]
+from = "/"
+to = "/.netlify/functions/render_pages"
+status = 200
+force = true
+
+[[redirects]]
+from = "/*"
+to = "/.netlify/functions/render_pages"
+status = 200
+
+
[dev]
port = 8888
command = "npm run start-all"
targetPort = 3000
+
+
+[context.dev.environment]
+CLIENT_DEV_ORIGIN = "http://127.0.0.1:3000"

Here, the environment variable CLIENT_DEV_ORIGIN is injected to the previewing so that the existence and the url origin of the client-side dev server is exposed.

On CLIENT_DEV_ORIGIN presented, when a get request to the functions/render_pages.tsx has its path ending with an non-empty file extension, the request is dispatched to the client-side dev server:

// src/server/functions/render_pages.tsx
import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions';
import axios from 'axios';
import path from 'node:path';

const { CLIENT_DEV_ORIGIN } = process.env;

export const handler: Handler = async (event, context) => {
  const shouldGetFromClientDevServer =
    CLIENT_DEV_ORIGIN && event.httpMethod === 'GET' && path.parse(event.path).ext;
  if (shouldGetFromClientDevServer) {
    return await getFromClientDevServer(event);
  }

  return { statusCode: 200, body: 'It works!' };
};

async function getFromClientDevServer(event: HandlerEvent): Promise<HandlerResponse> {
  const { status, data, headers } = await axios.get(`${CLIENT_DEV_ORIGIN}${event.path}`, {
    responseType: 'text',
    responseEncoding: 'binary',
  });
  return {
    statusCode: status,
    headers: headers as {},
    body: Buffer.from(data, 'binary').toString('base64'),
    isBase64Encoded: true,
  };
}

The rest requests are the ones for pages and SSR is to be added for them. To manipulate html, jsdom is installed:

$ npm i jsdom @types/jsdom

Then, src/server/functions/render_pages.tsx is extended along with src/pages/Home/ssrData.ts and src/types.ts created:

// src/server/functions/render_pages.tsx
import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions';
import { dehydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { JSDOM } from 'jsdom';
import path from 'node:path';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { App } from '../../App';
import { createQueryClient } from '../../queries/queryClient';
import { ParamsOfFillInSsrData } from '../../types';

const { CLIENT_DEV_ORIGIN } = process.env;

export const handler: Handler = async (event, context) => {
  const shouldGetFromClientDevServer =
    CLIENT_DEV_ORIGIN && event.httpMethod === 'GET' && path.parse(event.path).ext;
  if (shouldGetFromClientDevServer) {
    return await getFromClientDevServer(event);
  }

  const clientIndexDom = new JSDOM(await getClientIndexHtml());
  const { document } = clientIndexDom.window;

  try {
    const queryClient = createQueryClient();
    await autoFillInSsrData({ event, context, queryClient });
    addDehydratedScript(document, queryClient);
    const rootHtml = renderToString(
      <QueryClientProvider client={queryClient}>
        <StaticRouter location={event.path}>
          <App />
        </StaticRouter>
      </QueryClientProvider>
    );
    document.querySelector('#root')!.innerHTML = rootHtml;
    queryClient.clear();
  } catch (e) {
    console.error(e);
  }

  return { statusCode: 200, body: clientIndexDom.serialize() };
};

async function getFromClientDevServer(event: HandlerEvent): Promise<HandlerResponse> {
  const { status, data, headers } = await axios.get(`${CLIENT_DEV_ORIGIN}${event.path}`, {
    responseType: 'text',
    responseEncoding: 'binary',
  });
  return {
    statusCode: status,
    headers: headers as {},
    body: Buffer.from(data, 'binary').toString('base64'),
    isBase64Encoded: true,
  };
}

async function getClientIndexHtml(): Promise<string> {
  if (!getClientIndexHtml.cachedResult) {
    let result: string;
    if (CLIENT_DEV_ORIGIN) {
      const { data } = await axios.get(`${CLIENT_DEV_ORIGIN}/`, { responseType: 'text' });
      result = data;
    } else {
      result = '<div id="root"></div>';
    }
    getClientIndexHtml.cachedResult = result;
  }
  return getClientIndexHtml.cachedResult;
}
getClientIndexHtml.cachedResult = false as string | false;

async function autoFillInSsrData(params: ParamsOfFillInSsrData): Promise<void> {
  const { event } = params;

  let pageName: string | false = false;
  if (event.path === '/') {
    pageName = 'Home';
  }

  if (pageName) {
    const { fillInSsrData } = await import(`../../pages/${pageName}/ssrData`);
    await fillInSsrData(params);
  }
}

function addDehydratedScript(document: Document, queryClient: QueryClient): void {
  const scriptAsStr = `window.__REACT_QUERY_STATE__=${JSON.stringify(dehydrate(queryClient))};`;
  const scriptAsElm = document.createElement('script');
  scriptAsElm.innerHTML = scriptAsStr;
  document.head.append(scriptAsElm);
}
// src/pages/Home/ssrData.ts
import { paramOfGhRepoStarCountQuery } from './Page';
import { getKeyOfGhRepoStarCountQuery } from '../../queries/gh';
import { getGhRepoStarCount } from '../../server/gh/repo';
import type { ParamsOfFillInSsrData } from '../../types';

export async function fillInSsrData({ queryClient }: ParamsOfFillInSsrData): Promise<void> {
  await queryClient.prefetchQuery(getKeyOfGhRepoStarCountQuery(paramOfGhRepoStarCountQuery), () =>
    getGhRepoStarCount(paramOfGhRepoStarCountQuery)
  );
}
// src/types.ts
import type { HandlerContext, HandlerEvent } from '@netlify/functions';
import type { QueryClient } from '@tanstack/react-query';

export type ParamsOfFillInSsrData = {
  event: HandlerEvent;
  context: HandlerContext;
  queryClient: QueryClient;
};

For each page that requests server-side data, a ssrData.ts file is given for preparing its own SSR data. The functions/render_pages.tsx selects the proper ssrData.ts file based on the request path to prepare the proper SSR data. Together with the client-side root React component <App>, the server-side rendered html is generated. Afterwards, the SSR data is serialized as the global variable __REACT_QUERY_STATE__ inside a script html element. Meanwhile, on CLIENT_DEV_ORIGIN presented, the top-level html is fetched from the client-side dev server. With the script html element and the server-side rendered html inserted, the top-level html is returned as the response.

That's the server-side part of SSR. For the client-side part, the global variable __REACT_QUERY_STATE__ is deserialized for the hydration:

// src/react-app-env.d.ts
/// <reference types="react-scripts" />

+interface Window {
+  __REACT_QUERY_STATE__?: {};
+}
// src/index.tsx
-import { QueryClientProvider } from '@tanstack/react-query';
+import { Hydrate, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
import { createQueryClient } from './queries/queryClient';
import reportWebVitals from './reportWebVitals';

const queryClient = createQueryClient();
+const dehydratedState = window.__REACT_QUERY_STATE__ ?? {};

-const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
-root.render(
+ReactDOM.hydrateRoot(
+  document.getElementById('root') as HTMLElement,
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
-      <BrowserRouter>
-        <App />
-      </BrowserRouter>
+      <Hydrate state={dehydratedState}>
+        <BrowserRouter>
+          <App />
+        </BrowserRouter>
+      </Hydrate>
    </QueryClientProvider>
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Now, with the previewing launched, visiting the path / or /zxcv verifies the SSR result of the Home/Page.tsx or NotFound/Page.tsx, which is also doable by using curl:

$ curl http://127.0.0.1:8888/
...
<div id="root"><div class="Page_root__VJmx-"><header class="Page_header__LpSVE"><img src="/static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg" class="Page_logo__KFvWL" alt="logo"><p>Edit <code>src/**/*.tsx</code> and save to reload.</p><a class="Page_link__iAwDC" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React (⭐️ = <!-- -->201,386<!-- -->)</a></header></div></div>
...

$ curl http://127.0.0.1:8888/zxcv
...
<div id="root"><div class="Page_root__3XcfJ"><h1>Not Found</h1></div></div>
...

Wth the SSR added in the previewing, a flash of unstyled content(FOUC) can happen because, on browser, the server-side rendered html appears prior to the styles inserted on the JS files loaded. Though, it's expected when style-loader is used. CRA uses style-loader in react-scripts start to do the hot module replacement(HMR) for the styles in the previewing. But when the app gets built and deployed to the remote environment, CRA inserts the styles into the top-level html at compile time in react-scripts build, so the FOUC problem won't exist in the end. (But if you still think the FOUC problem matters, controlling the visibility of the server-side rendered html with checking process.env.NODE_ENV === 'development' may help.)

With the previewing made ready, the demo app is getting built and deployed to the remote environment. In the remote environment, there is no client-side dev server launched but are only the built client-side assets. And I need to extend package.json, netlify.toml and src/server/functions/render_pages.tsx:

// package.json
...
"scripts": {
    "start:client": "cross-env BROWSER=none FAST_REFRESH=false react-scripts start",
    "start:server": "cross-env BUILD_PATH=server webpack -w -c webpack.server.config.js",
    "start-all": "run-p start:*",
    "dev": "netlify dev",
-    "build": "react-scripts build",
+    "build:client": "cross-env BUILD_PATH=client react-scripts build",
+    "build:server": "cross-env BUILD_PATH=server NODE_ENV=production webpack -c webpack.server.config.js",
+    "build-all": "run-s build:*",
...
# netlify.toml
[functions]
directory = "server/server/functions"
+node_bundler = "nft"
+included_files = ["server/**/*", "client/**/*.html"]
...
+[build]
+command = "npm run build-all"
+publish = "client"
+
+[build.environment]
+NODE_ENV = "production"
...
// src/server/functions/render_pages.tsx
import { Handler, HandlerEvent, HandlerResponse } from '@netlify/functions';
import { dehydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import axios from 'axios';
import { JSDOM } from 'jsdom';
+import fs from 'node:fs';
import path from 'node:path';
+import { promisify } from 'node:util';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { App } from '../../App';
import { createQueryClient } from '../../queries/queryClient';
import { ParamsOfFillInSsrData } from '../../types';

const { CLIENT_DEV_ORIGIN } = process.env;
+const CLIENT_INDEX_HTML_PATH = path.resolve('client/index.html');
...
async function getClientIndexHtml(): Promise<string> {
  if (!getClientIndexHtml.cachedResult) {
    let result: string;
    if (CLIENT_DEV_ORIGIN) {
      const { data } = await axios.get(`${CLIENT_DEV_ORIGIN}/`, { responseType: 'text' });
      result = data;
    } else {
-      result = '<div id="root"></div>';
+      result = await promisify(fs.readFile)(CLIENT_INDEX_HTML_PATH, 'utf8');
    }
    getClientIndexHtml.cachedResult = result;
  }
  return getClientIndexHtml.cachedResult;
}
getClientIndexHtml.cachedResult = false as string | false;
...

The build directory of the client-side assets is adjusted from build to client and is used as the publish directory of Netlify. In the remote environment, with the current routing options of Netlify, for a get request whose path matches an asset path in the publish directory, the matched asset is returned. Meanwhile, in the functions/render_pages.tsx, the top-level html is read from the file client/index.html. Notice that, as the server-side transpilation is already done, node_bundler = "nft" is used in netlify.toml to stop Netlify doing any extra processing in the deploying.

By the way, to untrack the generated directory client, .gitignore needs to be modified:

# .gitignore
...
# production
-/build
+/client
/server
...

Finally, following the guide Import from an existing repository in the Netlify docs, the demo app is deployed. The final codebase of the demo app is in the GitHub repo licg9999/server-side-rendering-with-cra-on-netlify. And its url after the deployment is https://bucolic-sprinkles-002beb.netlify.app/. The SSR can be verified by visiting the path / or /zxcv, which is also doable by using curl:

$ curl https://bucolic-sprinkles-002beb.netlify.app/
...<div id="root"><div class="Page_root__VJmx-"><header class="Page_header__LpSVE"><img src="/static/media/logo.6ce24c58023cc2f8fd88fe9d219db6c6.svg" class="Page_logo__KFvWL" alt="logo"><p>Edit <code>src/**/*.tsx</code> and save to reload.</p><a class="Page_link__iAwDC" href="https://reactjs.org" target="_blank" rel="noopener noreferrer">Learn React (⭐️ = <!-- -->201,431<!-- -->)</a></header></div></div>...

$ curl https://bucolic-sprinkles-002beb.netlify.app/zxcv
...<div id="root"><div class="Page_root__3XcfJ"><h1>Not Found</h1></div></div>...

By far, the demo app of a React app that has commonly seen features and is deployed on Netlify with SSR added is fully constructed.

The idea under the hood

Going over the construction of the demo app, the idea of setting up SSR for any React app on any FaaS provider is summarized as below:

  1. A server side that is able to import and use the client-side root React component needs to be made first. The client-side compilation config is as much as possibly reused to create the server-side transpilation config, with the key adjustments of preserving the directory structure and stopping assets getting emitted, for the files of the FaaS entries.
  2. For each page that requests server-side data, a SSR sibling file is given for preparing its own SSR data. On a page request coming, the server side selects the proper SSR sibling file based on the request path to prepare the proper SSR data. The logics and types of the data accessing to the data source can be extracted, then be reused by the client-side data fetching, the server-side data returing and the SSR data preparing.
  3. The SSR data together with the client-side root component are used to generate the server-side rendered html. The top-level html can be fetched from the client-side dev server in the local environment (or the previewing), or be read from the built client-side assets in the remote environment. With the SSR data serialized then inserted along with the server-side rendered html, the top-level html is returned as the response.
  4. The client side deserializes the SSR data from the response, then uses it together with the root client-side React component to hydrate the server-side rendered html from the response to initialize the React client side.
  5. The extra processing by the FaaS provider in the deploying needs to be stopped, if there is any, because the server-side transpilation is already done. Also, some dispatching needs to be added to help launch the app as a whole in the local environment.

Among the points, I think the #1 can be the most important one. The reason is, the rest ones are only suggesting possible best practices, but the server side that is able to import and use the client-side root React component always constitutes the foundation of SSR. So, once the server-side transpilation can be made from the reuse of the client-side compilation config, just like this demo app of SSR with CRA on Neltify, SSR can be added to any React app on any FaaS provider.

相關文章