Server-Side Rendering (SSR) with Vite


Introduction
Server side rendering (SSR) is a technique where a page with all of its content is generated on the server and sent to the browser fully populated. This technique helps with SEO and makes the site feel snappier on initial load, although it adds implementation complexity. In the old days of the web everything was server side generated, but then single page apps (SPAs) were introduced and they offered a much better user experience. These days modern frameworks based on React use a hybrid approach which works like this:
A page is generated on the server, populated with data and sent to the browser
On the browser side the page is visible immediately and then the client side script adds interactivity and event handlers
As you navigate through the app with links you get the interactivity of a SPA
Internal pages such as dashboards and editors where SEO doesn't matter are just regular SPAs
This article will focus on a high-level overview on how to take a SPA built with the popular build tool Vite and add SSR to it using native Vite tools, without committing to a framework. If you browse online through Reddit and various forums, you’ll find that many teams are asking questions like: “How do I build the home page with SSR and then the rest of the app with Vite”. There aren’t many resources with definitive answers except that it’s possible. This article aims to shed some light on this entire process and how it worked out for us.
Motivation for SSR in Ablo
Ablo offers a comprehensive editor tool where artists create beautiful designs, but it also offers a storefront where consumers can buy these designs. The latter has to be indexable by Google and easily discoverable on the web because we want to maximize the traffic we get on these pages. Maximizing traffic on every e-commerce related page means more people going into the sales funnel and ultimately more sales. These are pages used for marketing such as the home page or the page with a list of products or product details. If we only had these kind of pages, we would be better served with a framework such as NextJS or Remix.
However, the bulk of our app is the entire ecosystem around the editor and all the admin pages focused around publishing and managing designs. At the time of this writing we’re about to build a huge creator hub module with tools which artists will use to maximize their earnings. All of these pages are behind a login and use client-side libraries such as fabric.js or later on charting libraries for dashboards. They aren’t indexed by Google so they don’t need SEO considerations and SSR support. Since its inception Ablo has been a pure SPA centered around design tools and AI image generation. The merch shop and e-commerce aspect came later. Like many SPAs out there, Ablo uses Vite as its build tool because of unparalleled simplicity, flexibility, and speed of development. Unlike frameworks it doesn’t force you into a specific paradigm. It just does its job and gets out of the way.
Like many apps, when Ablo recently got the requirement to have a subset of its pages rendered on the server, we evaluated different solutions. Thankfully, Vite has SSR support and although it’s not a fully fledged SSR framework, it provides tools to effectively and easily build an SSR solution while maintaining all the benefits previously mentioned. For us this meant that we got to keep our existing workflows and implement SSR on a few of our pages with minimal effort.
A worthy mention is vite-ssr
, a framework for SSR with Vite. Their site has a comprehensive comparison with popular frameworks and it makes a great sales pitch on all of its benefits. However, we decided not to go with this solution because it’s paid and we saw a clear path on how to achieve our goals with a custom solution.
Measuring the results
Our end goal with SSR was to improve page speed load and ultimately SEO, because the latter depends on the former. There are many free and paid tools online which measure page speed load, but the first step is to start with Google’s Lighthouse. It can be ran from Chrome DevTools to get fast feedback during development on how different approaches affect performance. Here’s our score for Ablo’s home page on a pure SPA without SSR:
As you can see, it’s not very good. This was our starting point. After we implemented SSR and also added a couple of optimizations on how we store and serve images, it’s much better:
Getting started
Vite has good documentation on how to work with SSR, but they go into too much details on some things so you miss out on what’s important. We’ll present a bare bones version that’s already in production in our case, works well, and took a couple of days to implement. If we assume that you already have a Vite app, the first thing is to install Express: npm i express
because you’ll need a server to serve the pre-generated static pages. Here’s the basic idea of SSR with Vite:
In dev mode you use Vite’s Hot Module Reload (HMR) server as Express middleware. This allows for instant feedback in the browser as you make changes in the code. That’s a feature that everyone got used to when working with Vite and you can still use it.
In production the Vite app is built into a
dist
folder as usual, without SSR. However, in this case the Express server reads this HTML and pre-renders data before sending it to the client.There are two entry points to the app:
entry-client.tsx
andentry-server.tsx
. For existing Vite apps the client entry point is the same main file which they currently use. The server entry point has all the pre-rendering logic and a static router for routes with SSR.In production the client entry point is built into the client bundle as described in point 2, and the server side is also built into its own distribution folder.
The entry point to the server (Express) side is the server.js
file. It’s a simple file which embeds the logic described above.
import express from 'express';
import fs from 'node:fs/promises';
const base = process.env.BASE || '/';
const isProduction = process.env.NODE_ENV === 'production';
// Cached production assets
const templateHtml = isProduction ? await fs.readFile('./dist/client/index.html', 'utf-8') : '';
// Create http server
const app = express();
// Add Vite or respective production middlewares
/** @type {import('vite').ViteDevServer | undefined} */
let vite;
if (!isProduction) {
// In development we use the good old Vite HMR server, but as an Express middleware here
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: [] }));
}
// This is a catch all route used as en entry point to render the initial page
app.use('*all', async (req, res, next) => {
const url = req.originalUrl;
try {
let template;
/** @type {import('./src/entry-server.js').render} */
let render;
if (!isProduction) {
template = await fs.readFile('./index.html', 'utf-8');
template = await vite.transformIndexHtml(url, template);
// The entry point to SSR for the initial load in dev mode
render = (await vite.ssrLoadModule('/src/entry-server.tsx')).render;
} else {
// In production the entry-server file is built into a distribution folder
template = templateHtml;
render = (await import('./dist/server/entry-server.js')).render;
}
// The entry-server file always has to implement a "render" method which is used to generate
// the HTML that will be returned to the browser on initial load
const {
head,
html: appHtml,
dehydratedState: initialData,
} = await render({
path: url.split('?')[0],
userAgent: req.headers['user-agent'],
});
// The Helmet part is to support meta tags, which is a topic for a future article
const html = template
.replace(`<!--helmet-outlet-->`, () => head)
// The index html has a comment area which is replaced with the actual HTML on initial load
.replace(`<!--ssr-outlet-->`, () => appHtml)
// This part is to inject preloaded data into the page immediately, without fetching it on
// client side. For example, you preload products on the server and inject the entire
// resulting JSON into the HTML which is then picked up by the client side code
.replace(
'<!--dehydrated-state-->',
`<script>window.__REACT_QUERY_STATE__ = ${JSON.stringify(initialData)}</script>`
);
// Send the rendered HTML back.
res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
} catch (e) {
// If an error is caught, let Vite fix the stack trace so it maps back
// to your actual source code.
console.log('Err', e);
vite.ssrFixStacktrace(e);
next(e);
}
});
app.listen(5173);
There are two important parts in the code above:
<!--ssr-outlet-->
- this is a comment in the index.html which tells the server where to inject the pre-rendered HTML. This is a fully populated document structure which you can see coming back from the server if you filter by “Doc” in Chrome DevTools:
2. <!--dehydrated-state-->
- this comment marks the area where the server will inject data pre-loaded on the server. For example, on our Merch Shop page above we have a list of brands. In an SPA, the page would load these brands from the server on mount and show a loading spinner. With SSR we preload the brands on the server, but we have to inject them somehow into the client side code. We do that by serializing it and replacing this comment with the serialized state. On the client, a library like `react-query` picks up this state and just renders the data. This is a topic for the next installment of this article series.
Server entry
// src/entry-server.tsx
import { renderToString } from 'react-dom/server';
import { ChakraProvider } from '@chakra-ui/react';
import { dehydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Route, StaticRouter } from 'react-router-dom';
import theme from '@/theme';
import HomeSignedIn from './views/home/HomeSignedIn';
import SelectTemplatePage from './views/template/SelectTemplatePage';
import { getCategories } from './api/templates';
import './index.css';
import ProductsPageAuthenticated from './views/products/ProductsPageAuthenticated';
import ProductDetailsPage from './views/product/ProductDetailsPage';
import { Helmet } from 'react-helmet';
import CreatorHubSignedIn from './views/creator-hub/CreatorHubSignedIn';
interface IRenderProps {
path: string;
}
export async function render({ path }: IRenderProps) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
},
},
});
await queryClient.prefetchQuery(['templates', 'categories'], () => getCategories());
const html = renderToString(
<ChakraProvider theme={theme}>
<QueryClientProvider client={queryClient}>
<StaticRouter location={path}>
<Route exact path="/" render={() => <HomeSignedIn />}></Route>
<Route
exact
path="/shop/category/:categorySlug"
render={() => <ProductsPageAuthenticated />}
></Route>
<Route
path="/shop/community/:brandIdOrSlug/:categorySlug"
render={() => <ProductsPageAuthenticated />}
></Route>
<Route path="/shop/:idOrSlug" render={() => <ProductDetailsPage />}></Route>
<Route path="/design-studio" render={() => <SelectTemplatePage />}></Route>
<Route path="/creator-hub" render={() => <CreatorHubSignedIn />}></Route>
</StaticRouter>
</QueryClientProvider>
</ChakraProvider>
);
const helmet = Helmet.renderStatic();
const head = `
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
`;
const dehydratedState = dehydrate(queryClient);
return {
head,
html,
dehydratedState,
};
}
The server entry file looks like a normal entry file to a React app on the client side, with a top level router. What’s different is that it uses the renderToString
method to turn the React component tree to raw HTML. We use Chakra UI as our styling library and you can see how it can seamlessly be used in SSR. Another thing to notice here is how we pre-hydrate the react-query
query client. We do the same API calls as we would on the client side here on the server and then store them into react-query
‘s internal state. At the end we pull all of the query client’s internal state to dehydratedState
which is then used in entry-client
as you’ll see in the next section.
Client entry
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import { isAxiosError } from 'axios';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import { IntercomProvider } from 'react-use-intercom';
import * as Sentry from '@sentry/react';
import AdminDashboard from '@/layouts/admin';
import Auth from '@/layouts/auth';
import { ChakraProvider } from '@chakra-ui/react';
import ResetPasswordPage from '@/views/auth/reset-password';
import theme from '@/theme';
import { QueryClient, QueryClientProvider, DehydratedState, Hydrate } from '@tanstack/react-query';
import { GoogleOAuthProvider } from '@react-oauth/google';
import Config from './config';
import { PageTracker } from './analytics/PageTracker';
import './index.css';
import { Helmet } from 'react-helmet';
import DEFAULT_TOAST_OPTIONS from './theme/toast';
declare global {
interface Window {
__REACT_QUERY_STATE__: DehydratedState;
}
}
const { ENVIRONMENT, GOOGLE_CLIENT_ID, INTERCOM_APP_ID, SENTRY_DSN } = Config;
const container = document.getElementById('app');
if (import.meta.env.PROD) {
Sentry.init({
dsn: SENTRY_DSN,
environment: ENVIRONMENT,
integrations: [new Sentry.BrowserTracing(), new Sentry.Replay()],
tracesSampleRate: 0,
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
}
const MAX_RETRIES = 6;
const HTTP_STATUS_TO_NOT_RETRY = [400, 401, 403, 404, 409];
const dehydratedState = window.__REACT_QUERY_STATE__;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: (failureCount, error) => {
if (failureCount > MAX_RETRIES) {
return false;
}
if (isAxiosError(error) && HTTP_STATUS_TO_NOT_RETRY.includes(error.response?.status ?? 0)) {
console.log(`Aborting retry due to ${error.response?.status} status`);
return false;
}
return true;
},
},
},
});
hydrateRoot(
container,
<React.StrictMode>
<IntercomProvider appId={INTERCOM_APP_ID}>
<GoogleOAuthProvider clientId={GOOGLE_CLIENT_ID}>
<QueryClientProvider client={queryClient}>
<Hydrate state={dehydratedState}>
<ChakraProvider theme={theme} toastOptions={{ defaultOptions: DEFAULT_TOAST_OPTIONS }}>
<BrowserRouter>
<PageTracker />
<Helmet>
{ENVIRONMENT !== 'production' ? <meta content="noindex" name="robots" /> : null}
<title>ABLO – AI‑Powered Fashion & Custom Merch</title>
<meta
name="description"
content="Create premium fashion with AI. Collaborate with iconic brands & design merch in minutes. Shop unique pieces from the creators you love."
/>
</Helmet>
<Switch>
<Route path={`/auth`} component={Auth} />
<Route path={`/reset-password`} component={ResetPasswordPage} />
<Route path={`/`} component={AdminDashboard} />
</Switch>
</BrowserRouter>
</ChakraProvider>
</Hydrate>
</QueryClientProvider>
</GoogleOAuthProvider>
</IntercomProvider>
</React.StrictMode>
);
The client entry file is just a React app, but as described before, you’ll notice how we load the server query client’s state into the client side query client. On the server we preloaded all the data and stored it into a string. We loaded this string into the query client on the client side. This will be explained in more detail in a future article.
New commands in package.json
Here’s the end state of our scripts in package.json:
"scripts": {
"dev": "vite",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 20",
"typecheck": "tsc --noEmit",
"build:library": "tsc && vite build --config vite-lib.config.ts",
"build:fabric": "cd node_modules/fabric && npm run build_with_gestures",
"preview": "vite preview",
"build:client": "vite build --outDir dist/client",
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
"build:ssr": "npm run build:client && npm run build:server",
"build": "vite build",
"start": "node server.js",
"start:prod": "NODE_ENV=production npm run start"
},
When in development and you don’t need to test or work with SSR you use npm run dev
. When you need to work with SSR in development you use npm run start
. For production you first build the app with npm run build:ssr
and then you run npm run start:prod
Conclusion
This should explain basic strategies around implementing SSR with Vite and get you running with your own implementation. Future installments of this article series will go deeper into specific concepts and details such as working with data.
Subscribe to my newsletter
Read articles from Mihovil Kovacevic directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
