Building SSR Application with Vite.

Building SSR Application with Vite.

Before diving, if you don't know what a SSR application is let me give you a brief about it. SSR is the method of generating HTML at server side instead of in client browser (CSR).

With growing list of frameworks we just are building applications where we write code in Javascript and let the bundlers, browsers to handle the generation of HTML. Example we have React, with react we're doing CSR because the code you've written is rendered on client-side thus it's called CSR, if it does at server-side then it's SSR.

How react is CSR?, if you ever built a react-application you should've seen the following script in the index.html file we started application

<noscript>You need to enable JavaScript to run this app.</noscript>

Well, if you understand what CSR and SSR is, let's dive into creating a Server Side application.

Checkout this article for more info on CSR vs SSR: https://www.freecodecamp.org/news/server-side-rendering-javascript/

How?

We're going to use vite as the bundler as vite does have some default helpful functions that make SSR easier.

Before diving in, we should know two functions from react itself i.e renderToString and hydrateRoot.

renderToString will take a component and create's HTML tree for us to paint on the browser -> renderToString

hydrateRoot lets you display React components inside a browser DOM node whose HTML content was previously generated by -> hydrateRoot

Using vite as our bundler gives us the ability spin up SSR easily.

Tech-stack

  • React, obviously

  • Vite - as a bundler

  • React router - Routing & data provider

  • Styled-component - Styling.

  • express - running server on

To Start a basic repo you can run the following command npx create vite-app and follow the instructions given by CLI to create the app.

Once everything is done, you can use the default node package manager to install the run the commands to start server or build server.

// I am using pnpm as my package manager
pnpm install // install the packages
pnpm dev // start dev server

Creating a Server.

We're aiming for Server side rendering, so we obviously have to render it in server. we have to use renderToString function provided from react-dom/server which let's us to render a react component in server side without need of browser.

Let's create a file named entry-server.jsx under /src folder.

Doesn't have to be entry-server.jsx but sure to have .jsx extension and remember name.

import React from "react";
import ReactDOMServer from "react-dom/server";
import {StaticRouter} from "react-router-dom/server";
import App from "./App";
import { ServerStyleSheet } from "styled-components";

// This syntax is important, 
// which keeps the same export name even after bundling.
export function render(url) {
  const sheet = new ServerStyleSheet();

  const html = ReactDOMServer.renderToString(
    sheet.collectStyles(
      <React.StrictMode>
          <StaticRouter url={url}>
            <App />
          </StaticRouter>
      </React.StrictMode>
    )
  );

  const styleTags = sheet.getStyleTags();

  return { html, styles: styleTags };
}

Creating a Client.

The Rendering happened at Server side should be hydrated at client-side to make all the react code to work as we expected.

Don't be fear of hydration errors, leave them.

Let's create entry-client.jsx inside /src folder.

Same rules, your code your filename :)

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { BrowserRouter } from "react-router-dom";

ReactDOM.hydrateRoot(
  document.getElementById("app"),
  <React.StrictMode>
       <BrowserRouter>
        <LazyMotion features={loadFeatures}>
          <App />
        </BrowserRouter>
    </UserContextProvider>
  </React.StrictMode>
);

hydrate lets you display React components inside a browser DOM node whose HTML content was previously generated by react-dom/server.

We have the rest of react ready i.e <App /> component.

Now we need to serve the HTML over the server, so we need a server code to run the server.

server.js in root of your folder.

import fs from "node:fs/promises";
import express from "express";

// Constants
const isProduction = process.env.NODE_ENV === "production";
const port = process.env.PORT || 5000;
const base = process.env.BASE || "/";

let handler;
// Cached production assets
const templateHtml = isProduction
  ? await fs.readFile("./dist/client/index.html", "utf-8")
  : "";
const ssrManifest = isProduction
  ? await fs.readFile("./dist/client/.vite/ssr-manifest.json", "utf-8")
  : undefined;

// Create http server
const app = express();

// Add Vite or respective production middlewares
let vite;
if (!isProduction) {
  const { createServer } = await import("vite");
  vite = await createServer({
    server: { middlewareMode: true },
    appType: "custom",
    base,
  });
  app.use(vite.middlewares);
} else {
  const compression = (await import("compression")).default;
  const sirv = (await import("sirv")).default;
  app.use(compression());
  app.use(
    base,
    sirv("./dist/client", {
      extensions: [],
      // We're compressing to brotli
      brotli: true,
    })
  );
}

// Serve HTML
app.use("*", async (req, res) => {
  try {
    const url = req.originalUrl.replace(base, "");
    let template;
    let render;
    if (!isProduction) {
      // Always read fresh template in development
      template = await fs.readFile("./index.html", "utf-8");
      template = await vite.transformIndexHtml(url, template);
      render = (await vite.ssrLoadModule("/src/entry-server.jsx")).render;
    } else {
    // Code that is compiled.
      template = templateHtml;
      render = (await import("./dist/server/entry-server.js")).render;
    }

    // Here we need req.originalUrl as `location` param in `StaticRouter` param is expecting path with a forward-slash(/)
    const rendered = await render(
      req.originalUrl,
    );

    const html = template
      .replace(`<!--app-head-->`, rendered.head ?? "")
      .replace(`<!--app-html-->`, rendered.html ?? "")

      // Created for our own purpose
      // We inject custom styles/script values from server to reduce latency
      .replace(`/* app-script */`, rendered.script ?? "")
      .replace("<!--app-style-->", rendered.styles ?? "");

    res.status(200).set({ "Content-Type": "text/html" }).end(html);
  } catch (e) {
    vite?.ssrFixStacktrace(e);
    console.log(e.stack);
    res.status(500).end(e.stack);
  }
});

// Start http server
app.listen(port, async () => {
  console.log(`Server started at http://localhost:${port}`);
});

Now that we have server.js to run the server and serve our react based HTML code we need to prepare out html in which our generated react will be appended to.

If we observe the code inside server.js we're appending few special strings to index.html before sending it over server as string.

The below block of code.

const html = template
      .replace(`<!--app-head-->`, rendered.head ?? "")
      .replace(`<!--app-html-->`, rendered.html ?? "")

      // Created for our own purpose
      // We inject custom styles/script values from server to reduce latency
      .replace(`/* app-script */`, rendered.script ?? "")
      .replace("<!--app-style-->", rendered.styles ?? "");
<html>
<head>
  <title>Sunfox Solar Cost and Savings Calculator</title>
   <!--app-style--> Custom style injected at rendering time.
  <!--app-head--> Custom head component injected at rendering time.
</head>
<div id="app"><!--app-html--></div> Html content replaced by rendered code.
  <script type="module" src="/src/entry-client.jsx"></script>
  <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.

      You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.

      To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  <script>
    /* app-script */ Any custom JS code to add to HTML, might be useful in case of using redux.
  </script>
</html>

Now that we have everything we needed, let me put a simple description about everyfile so that we don't have to go back&forth to make changes.

  • entry-server.jsx to Render the react at server-side, along with Router.

  • entry-client.jsx to Update the rendered react at browser side i.e hydrating

  • server.js to run the server which serves the generated react code.

  • index.html the template HTML which will append the generated react and serve over network.

Updating scripts inside package.json, since the base project was setup using vite we will be having this below template scripts section.

{
    "scripts": {
        "dev": "pnpm dev",
        "build": "pnpm build"
    }
}

Let's do the following changes into to package.json

{
    "scripts": {
        "dev": "node server.js",
        "build": "npm run build:client && npm run build:server",
        "build:client": "vite build --ssrManifest --outDir dist/client --minify esbuild",
        "build:server": "vite build --ssr src/entry-server.jsx --outDir dist/server --minify esbuild",
        "preview": "cross-env NODE_ENV=production node server.js"
    }
}

Let me explain the each command indivudally.

  • dev to run the dev server.

  • build to build the code for production.

  • preview start the server on production code.

This guide is more of a setup SSR with vite and React-router, if you're just checking/learning about vite SSR here's a good start for you. https://vitejs.dev/guide/ssr.html#server-side-rendering

https://reactrouter.com/en/main/guides/ssr

If you want more of clean experience of building SSR application with vite without the hassle, vike is the best place to start. https://vike.dev.

This content only covers the StaticRouter part of react-router, since v6 we also have an option to use loaders with router from react-router. if anybody want an implementation of that version too, please comment :).

Tips

// Example implementation of a hook to determine browser/server.
import { useEffect, useState } from "react";

export const useIsClient = () => {
  const [isClient, setIsClient] = useState();

  useEffect(() => {
    if (typeof window !== "undefined" && window.document; || !import.meta.env.SSR) {
      setIsClient(true);
    } else {
      setIsClient(false);
    }
  }, []);

  return { isClient };
};

References.

vitejs.dev

https://vitejs.dev/guide/ssr.html#server-side-rendering

https://reactrouter.com/en/main/guides/ssr

Thanks for your time ❤️.

Did you find this article valuable?

Support Mahesh Vagicherla by becoming a sponsor. Any amount is appreciated!