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:
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
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"
}
}
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"
]
}
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
};
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();
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();
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}`)
});
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>
);
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>
</>;
}
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>
</>;
}
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>;
}
Run
Finally, with esbuild scripts configured, the express server set up, and react SSR implemented. we can run our server:
npm run dev
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:
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.
-
…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.
Top comments (0)