React Server Side Components

From bibbleWiki
Jump to navigation Jump to search

Introduction

React by default downloads your bundles and generates HTML on the client. This approach change the process to generate HTML on the Server and send it to the client. This should.

  • Improve SEO as the HTML can now by read on the server by the robots
  • Improve the user experience as the HTML is rendered quicker
  • Maybe improve performance
  • Make the build a bit more complex

For this page we will deal with SSR where all of the code is on the server and React Server components where components are split between client and server.

Server Side Rendering (SSR)

This is where the who of application is rendered on the server. I am using vite so the setup of this is quite easy.You basically create an express service and server pages from this. The example I found was [here]

  • Express Server
  • Client Entry Point (for testing)
  • Server Entry Point
  • Amend Index.html
  • Packages.json scripts

Express Server

For the main part I created the Express Server

import type { Request, Response, NextFunction } from 'express'
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
import express from 'express'
import compression from 'compression'
import serveStatic from 'serve-static'
import { createServer as createViteServer } from 'vite'
const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const resolve = (p: string) => path.resolve(__dirname, p)

const getStyleSheets = async () => {
    try {
        const assetpath = resolve('dist/assets')
        const files = await fs.readdir(assetpath)
        const cssAssets = files.filter(l => l.endsWith('.css'))
        const allContent = []
        for (const asset of cssAssets) {
            const content = await fs.readFile(path.join(assetpath, asset), 'utf-8')
            allContent.push(`<style type="text/css">${content}</style>`)
        }
        return allContent.join('\n')
    } catch {
        return ''
    }
}

async function createServer(isProd = process.env.NODE_ENV === 'production') {
    const app = express()
    // Create Vite server in middleware mode and configure the app type as
    // 'custom', disabling Vite's own HTML serving logic so parent server
    // can take control
    const vite = await createViteServer({
        server: { middlewareMode: true },
        appType: 'custom',
        logLevel: isTest ? 'error' : 'info',
    })

    // use vite's connect instance as middleware
    // if you use your own express router (express.Router()), you should use router.use
    app.use(vite.middlewares)
    const requestHandler = express.static(resolve('assets'))
    app.use(requestHandler)
    app.use('/assets', requestHandler)

    if (isProd) {
        app.use(compression())
        app.use(
            serveStatic(resolve('dist/client'), {
                index: false,
            })
        )
    }
    const stylesheets = getStyleSheets()
    app.use('*', async (req: Request, res: Response, next: NextFunction) => {
        const url = req.originalUrl

        try {
            // 1. Read index.html
            let template = await fs.readFile(
                isProd ? resolve('dist/client/index.html') : resolve('index.html'),
                'utf-8'
            )

            // 2. Apply Vite HTML transforms. This injects the Vite HMR client, and
            //    also applies HTML transforms from Vite plugins, e.g. global preambles
            //    from @vitejs/plugin-react
            template = await vite.transformIndexHtml(url, template)

            // 3. Load the server entry. vite.ssrLoadModule automatically transforms
            //    your ESM source code to be usable in Node.js! There is no bundling
            //    required, and provides efficient invalidation similar to HMR.
            let productionBuildPath = path.join(__dirname, './dist/server/entry-server.mjs')
            let devBuildPath = path.join(__dirname, './src/client/entry-server.tsx')
            const { render } = await vite.ssrLoadModule(isProd ? productionBuildPath : devBuildPath)

            // 4. render the app HTML. This assumes entry-server.js's exported `render`
            //    function calls appropriate framework SSR APIs,
            //    e.g. ReactDOMServer.renderToString()
            const appHtml = await render(url)
            const cssAssets = isProd ? '' : await stylesheets

            // 5. Inject the app-rendered HTML into the template.
            const html = template.replace(`<!--app-html-->`, appHtml).replace(`<!--head-->`, cssAssets)

            // 6. Send the rendered HTML back.
            res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
        } catch (e: any) {
            !isProd && vite.ssrFixStacktrace(e)
            console.log(e.stack)
            // If an error is caught, let Vite fix the stack trace so it maps back to
            // your actual source code.
            vite.ssrFixStacktrace(e)
            next(e)
        }
    })
    const port = process.env.PORT || 7456
    app.listen(Number(port), '0.0.0.0', () => {
        console.log(`App is listening on http://localhost:${port}`)
    })
}

createServer()

Client Entry Point (for testing)

Created Entry Points, one the client in case we want to debug

import React from 'react'

import { createRoot, hydrateRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'

import { App } from './App'

import './index.css'

const container = document.getElementById('app')

const FullApp = () => (
    <React.StrictMode>
        <BrowserRouter>
            <App />
        </BrowserRouter>
    </React.StrictMode>
)

if (import.meta.hot || !container?.innerText) {
    const root = createRoot(container!)
    root.render(<FullApp />)
} else {
    hydrateRoot(container, <FullApp />)
}

Server Entry Point

And a server entry point

import React from 'react'

import ReactDOMServer from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'

import { App } from './App'

import './index.css'

export function render(url: string): string {
    // eslint-disable-next-line import/no-named-as-default-member
    return ReactDOMServer.renderToString(
        <React.StrictMode>
            <StaticRouter location={url}>
                <App />
            </StaticRouter>
        </React.StrictMode>
    )
}

Amend Index.html

Change the index.html

<!doctype html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <link rel="icon" type="image/svg+xml" href="/vite.svg" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Vite + React + TS</title>
    </head>
    <body>
        <div id="app" style="height: 100%; width: 100%"><!--app-html--></div>
        <script type="module" src="/src/client/entry-client.tsx"></script>
    </body>
</html>

Packages.json scripts

And here is the packages.json

...
  "scripts": {
    "dev:server": "nodemon --watch server.ts --exec 'ts-node --esm server.ts'",
    "dev:client": "yarn build:client && vite --config vite.config.ts dev",
    "build": "rimraf dist && tsc -p tsconfig.prod.json && yarn build:client && yarn build:server && yarn copy-files",
    "build:client": "vite build --outDir dist/client --ssrManifest",
    "build:server": "vite build --ssr src/client/entry-server.tsx --outDir dist/server",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "format": "prettier --write 'src/**/*.{js,jsx,ts,tsx,css,md,json}' --config ./.prettierrc",
    "preview": "vite preview"
  },
...