DEV Community

Cover image for A Simple Approach to SSR with React 19 and esbuild
Will Medina
Will Medina

Posted on

A Simple Approach to SSR with React 19 and esbuild

This blog explores how to create a lightweight, flexible React application with minimal tooling and frameworks by leveraging React's latest features. While frameworks like Next.js and Remix are excellent for many use cases, this guide is for those interested in having full control while retaining similar functionality.

With React 19 released and its new features to support SSR, I decided to experiment with creating a React application supporting SSR with minimal tooling but first let’s explore the essential features Next.js provides that we need to consider:

Feature Next.js
SSR (Server-Side Rendering) Built-in with minimal setup.
SSG (Static Site Generation) Built-in with getStaticProps.
Routing File-based routing.
Code Splitting Automatic.
Image Optimization Built-in with next/image.
Performance Optimizations Automatic (e.g., prefetching, critical CSS).
SEO Built-in tools like next/head.

Based on these features we are going to create a minimal setup; here is the step-by-step guide:

Table of Contents

Note: You can find the contents of this tutorial in my repo https://github.com/willyelm/react-app

Setup

As a prerequisite of our setup, we will need Node.js installed and the following packages:

  • react: Component-based and interactive UI library.
  • react-dom: Hydrates SSR content with React functionality.
  • react-router-dom: Handle Routes with React.
  • express: Node.js simple server for static and REST APIs.
  • esbuild: Transpile TypeScript and bundle JS, CSS, SVG, and other files.
  • typescript: Adding Typing to our source code.

Our setup will handle routing with express to serve static files, public files, and REST APIs. Then handle all the requests with react-router-dom. Once loaded in the browser our client bundle will hydrate our pre-rendered content and make our components interactive. Here is a diagram representation of this idea:

React App Diagram

The diagram illustrates how the express app will handle requests by pre-rendering React components on the server and returning HTML. On the client side, React hydrates these pre-rendered components to enable interactivity.

With this diagram in mind, let's create a folder structure:

react-app/             # This will be our workspace directory.
  - public/            
  - scripts/           
    - build.js         # Bundle our server and client scripts.
    - config.js        # esbuild config to bundle.
    - dev.js           # Bundle on watch mode and run server.
  - src/
    - App/             # Our components will be here.
      - App.tsx        # The main application with browser routing.
      - Home.tsx.      # Our default page component.
      - NotFound.tsx   # Fallback page for unmatched routes.
    - index.tsx        # Hydrate our pre-rendered client app.
    - main.tsx         # Server app with SSR components.
    - style.css        # Initial stylesheet.   
  package.json
  tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Let's add our dependencies and setup our package.json:

{
  "name": "react-app",
  "type": "module",
  "devDependencies": {
    "@types/express": "^5.0.0",
    "@types/node": "^22.10.2",
    "@types/react": "^19.0.2",
    "@types/react-dom": "^19.0.2",
    "esbuild": "^0.24.2",
    "typescript": "^5.7.2"
  },
  "dependencies": {
    "express": "^4.21.2",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-router-dom": "^7.1.0"
  },
  "scripts": {
    "build": "node scripts/build",
    "dev": "node scripts/dev",
    "start": "node dist/main.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: the "type": "module" property is required to allow node.js to run ESM scripts.

Since we will be using Typescript, We'll configure a tsconfig.json file:

{
  "compilerOptions": {
    "esModuleInterop": true,
    "verbatimModuleSyntax": true,
    "noEmit": true,
    "resolveJsonModule": true,
    "skipLibCheck": true,
    "strict": true,
    "lib": ["DOM", "DOM.Iterable", "ES2022"],
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "baseUrl": ".",
    "paths": {
      "src": [
        "./src/"
      ]
    }
  },
  "include": [
    "src"
  ],
  "exclude": [
    "node_modules"
  ]
}
Enter fullscreen mode Exit fullscreen mode

Configuring esbuild

Why esbuild? compared to other tools esbuild keeps things minimal, it is very fast(the fastest bundler as of today) and supports typescript and esm by default.

In this setup, we will use esbuild to create our dev and build scripts and transpile both client and server bundles. In this section we will work in the scripts folder of our workspace.

scripts/config.js: This file will contain a base configuration for the client and server bundle that will be shared for our scripts.

import path from 'node:path';
// Working dir
const workspace = process.cwd();
// Server bundle configuration
export const serverConfig = {
  bundle: true,
  platform: 'node', 
  format: 'esm',        // Support esm packages
  packages: 'external', // Omit node packages from our node bundle
  logLevel: 'error',
  sourcemap: 'external',
  entryPoints: {
    main: path.join(workspace, 'src', 'main.tsx') // Express app
  },
  tsconfig: path.join(workspace, 'tsconfig.json'),
  outdir: path.join(workspace, 'dist')
};

// Client bundle configuration
export const clientConfig = {
  bundle: true,
  platform: 'browser',
  format: 'esm',
  sourcemap: 'external',
  logLevel: 'error',
  tsconfig: path.join(workspace, 'tsconfig.json'),
  entryPoints: {
    index: path.join(workspace, 'src', 'index.tsx'), // Client react app
    style: path.join(workspace, 'src', 'style.css')  // Stylesheet
  },
  outdir: path.join(workspace, 'dist', 'static'),    // Served as /static by express
};
Enter fullscreen mode Exit fullscreen mode

scripts/dev.js: This script bundles both the client, and server app and runs the main server script in watch mode.

import { spawn } from 'node:child_process';
import path from 'node:path';
import { context } from 'esbuild';
import { serverConfig, clientConfig } from './config.js';
// Working dir
const workspace = process.cwd();
// Dev process
async function dev() {
  // Build server in watch mode
  const serverContext = await context(serverConfig);
  serverContext.watch();
  // Build client in watch mode
  const clientContext = await context(clientConfig);
  clientContext.watch();
  // Run server
  const childProcess = spawn('node', [
    '--watch',
    path.join(workspace, 'dist', 'main.js')
  ], {
    stdio: 'inherit'
  });
  // Kill child process on program interruption
  process.on('SIGINT', () => {
    if (childProcess) {
      childProcess.kill();
    }
    process.exit(0);
  });
}
// Start the dev process
dev();
Enter fullscreen mode Exit fullscreen mode

With this script, we should be able to run npm run dev as configured in the package.json in our workspace.

scripts/build.js: Similar to dev but we only need to enable minify.

import { build } from 'esbuild';
import { clientConfig, serverConfig } from './config.js';
// build process
async function bundle() {
  // Build server
  await build({
    ...serverConfig,
    minify: true
  });
  // Build client
  await build({
    ...clientConfig,
    minify: true
  });
}
// Start the build process
bundle();
Enter fullscreen mode Exit fullscreen mode

This script will generate our dist bundle ready for production by running npm run build and running the app using npm start.

Now that we have esbuild configured to bundle both our node and our client app let's start creating an express server and implement React SSR.

Express App and Routing

This is a simple application using Express static and middleware approach to serve static files, handle server routes, and route using react-router-dom.

src/main.tsx: This is the main Node.js application that initializes the server, handles routes with Express, and implements React SSR.

import path from 'node:path';
import express, { type Request, type Response } from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import { StaticRouter } from 'react-router';
import { App } from './App/App';

const app = express(); // Create Express App
const port = 3000; // Port to listen
const workspace = process.cwd(); // workspace
// Serve static files like js bundles and css files
app.use('/static', express.static(path.join(workspace, 'dist', 'static')));
// Server files from the /public folder
app.use(express.static(path.join(workspace, 'public')));
// Fallback to render the SSR react app
app.use((request: Request, response: Response) => {
  // React SSR rendering as a stream
  const { pipe } = renderToPipeableStream(
    <html lang="en">
      <head>
        <meta charSet="UTF-8" />
        <link rel='stylesheet' href={`/static/style.css`} />
      </head>
      <body>
        <base href="/" />
        <div id="app">
          <StaticRouter location={request.url}>
            <App />
          </StaticRouter>
        </div>
      </body>
    </html>,
    { 
      bootstrapModules: [ `/static/index.js` ], // Load script as modules
      onShellReady() {
        response.setHeader('content-type', 'text/html');
        pipe(response);
      }
    });
});
// Start server
app.listen(port, () => {
  console.log(`[app] listening on port ${port}`)
});
Enter fullscreen mode Exit fullscreen mode

src/index.tsx: On the client side to activate our components and make them interactive we need to "hydrate".

import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App/App';
// Hydrate pre-renderer #app element
hydrateRoot(
  document.getElementById('app') as HTMLElement,
  <BrowserRouter>
    <App />
  </BrowserRouter>
);
Enter fullscreen mode Exit fullscreen mode

React App

The react app will handle the routes using react-router-dom, our app will consist of a Home page and a NotFound page to test hydration we will add a counter button on the home page and take advantage of React 19 we will update the meta tags title and description.

src/App/Home.tsx: A very minimal FunctionComponent.

import { useState, type FunctionComponent } from 'react';
// Home Page Component
export const Home: FunctionComponent = () => {
  const [count, setCount] = useState(0);
  const update = () => {
    setCount((prev) => prev + 1);
  }
  return <>
    <title> App | Home </title>
    <meta name='description' content='This is my home page' />
    <h1> Home Page </h1>
    <p> Counter: {count} </p>
    <button onClick={() => update()}>Update</button>
  </>;
}
Enter fullscreen mode Exit fullscreen mode

src/App/NotFound.tsx: Default FunctionComponent when page not found.

import { type FunctionComponent } from 'react';
// Not Found Page Component
export const NotFound: FunctionComponent = () => {
  return <>
    <title> App | Not Found </title>
    <meta name='description' content='Page not found' />
    <h1>404</h1>
    <p> Page Not Found</p>
  </>;
}
Enter fullscreen mode Exit fullscreen mode

src/App/App.tsx: Setting up our App using react-router-dom.

import { type FunctionComponent } from 'react';
import { Routes, Route } from 'react-router';
import { Home } from './Home';
import { NotFound } from './NotFound';
// App Component
export const App: FunctionComponent = () => {
  return <Routes>
    <Route path="/">
      <Route index element={<Home />} />
      <Route path="*" element={<NotFound />} />
    </Route>
  </Routes>;
}
Enter fullscreen mode Exit fullscreen mode

Run

Finally, with esbuild scripts configured, the express server set up, and react SSR implemented. we can run our server:

npm run dev
Enter fullscreen mode Exit fullscreen mode

This will show a message [app] listening on port 3000. navigate to
http://localhost:3000 to check it out.

Testing SSR and SEO meta tags:

React App Test SEO

You can also find the source code of this tutorial in my repo willyelm/react-app

A Simple react app setup with SSR and esbuild

This project leverages the latest features of react@19, react-router-dom@7, and others to configure SSR.

Depdendencies

  • react: Component-based and interactive UI library.
  • react-dom: Hydrates SSR content with React functionality.
  • react-router-dom: Handle Routes with React.
  • express: Node.js simple server for static and REST APIs.
  • esbuild: Transile TypeScript and bundle JS, CSS, SVG, and other files.
  • typescript: Adding Typing to our source code.

Project Structure

react-app/             # This will be our workspace directory.
  - public/
  - scripts/           
    - build.js         # Bundle our server and client scripts.
    - config.js        # esbuild config to bundle.
    - dev.js           # Bundle on watch mode and run server.
  - src/
    - App/             # Our components will be here.
      - App.tsx        # The main application with browser routing.
      - Home.tsx.      # Our default page component.
      - NotFound.tsx   # Fallback request to not found.
    -
Enter fullscreen mode Exit fullscreen mode

Next Steps

In this guide, we built a React app with SSR using esbuild and Express, focusing on a minimal and flexible setup. Using modern tools like React 19, React Router DOM 7 and esbuild, we achieved a fast and efficient workflow while avoiding the overhead of larger frameworks.

We can enhance this implementation to include the following:

  • TailwindCSS: Configure PostCSS and esbuild to support tailwindcss for styling.
  • Jest: Add unit testing with Jest and @testing-library.
  • Playwright: Configure End to End testing with playwright.
  • SSG: Implement SSG(Static site generation) to improve performance and server loading.
  • HMR: Support HMR(Hot Module Replacement) with esbuild in development mode to enhance efficiency.

Thank for reading and happy coding.

References

Top comments (0)