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:
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:
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.
importpathfrom'node:path';// Working dirconstworkspace=process.cwd();// Server bundle configurationexportconstserverConfig={bundle:true,platform:'node',format:'esm',// Support esm packagespackages:'external',// Omit node packages from our node bundlelogLevel:'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 configurationexportconstclientConfig={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 appstyle: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';importpathfrom'node:path';import{context}from'esbuild';import{serverConfig,clientConfig}from'./config.js';// Working dirconstworkspace=process.cwd();// Dev processasyncfunctiondev(){// Build server in watch modeconstserverContext=awaitcontext(serverConfig);serverContext.watch();// Build client in watch modeconstclientContext=awaitcontext(clientConfig);clientContext.watch();// Run serverconstchildProcess=spawn('node',['--watch',path.join(workspace,'dist','main.js')],{stdio:'inherit'});// Kill child process on program interruptionprocess.on('SIGINT',()=>{if (childProcess){childProcess.kill();}process.exit(0);});}// Start the dev processdev();
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.
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.
importpathfrom'node:path';importexpress,{typeRequest,typeResponse}from'express';import{renderToPipeableStream}from'react-dom/server';import{StaticRouter}from'react-router';import{App}from'./App/App';constapp=express();// Create Express Appconstport=3000;// Port to listenconstworkspace=process.cwd();// workspace// Serve static files like js bundles and css filesapp.use('/static',express.static(path.join(workspace,'dist','static')));// Server files from the /public folderapp.use(express.static(path.join(workspace,'public')));// Fallback to render the SSR react appapp.use((request:Request,response:Response)=>{// React SSR rendering as a streamconst{pipe}=renderToPipeableStream(<htmllang="en"><head><metacharSet="UTF-8"/><linkrel='stylesheet'href={`/static/style.css`}/>
</head>
<body><basehref="/"/><divid="app"><StaticRouterlocation={request.url}><App/></StaticRouter>
</div>
</body>
</html>,
{bootstrapModules:[`/static/index.js`],// Load script as modulesonShellReady(){response.setHeader('content-type','text/html');pipe(response);}});});// Start serverapp.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".
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,typeFunctionComponent}from'react';// Home Page ComponentexportconstHome:FunctionComponent=()=>{const[count,setCount]=useState(0);constupdate=()=>{setCount((prev)=>prev+1);}return<><title>App|Home</title>
<metaname='description'content='This is my home page'/><h1>HomePage</h1>
<p>Counter:{count}</p>
<buttononClick={()=>update()}>Update</button>
</>;
}
src/App/NotFound.tsx: Default FunctionComponent when page not found.
import{typeFunctionComponent}from'react';// Not Found Page ComponentexportconstNotFound:FunctionComponent=()=>{return<><title>App|NotFound</title>
<metaname='description'content='Page not found'/><h1>404</h1>
<p>PageNotFound</p>
</>;
}
src/App/App.tsx: Setting up our App using react-router-dom.
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.
-
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.
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.
In the next post, we’ll complete the setup by adding CSR,
TailwindCSS for styling, Jest for testing, and live reload to
enhance the development experience.