Vite’s SSR setup can feel like a black box, but it’s actually a surprisingly elegant way to get universal JavaScript rendering without the boilerplate of older tools.
Let’s see it in action. Imagine we have a simple Vue app.
<!-- src/App.vue -->
<template>
<div>
<h1>{{ message }}</h1>
<button @click="changeMessage">Change Message</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const message = ref('Hello from Vite SSR!');
const changeMessage = () => {
message.value = 'Message changed on the client!';
};
</script>
And here’s the minimal Vite config for SSR:
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
input: {
app: resolve(__dirname, 'index.html'), // Client entry
ssr: resolve(__dirname, 'src/entry-server.js'), // Server entry
},
output: {
entryFileNames: '[name].js', // Ensure entry files are named as expected
format: 'es', // Use ES modules for server build
},
},
},
});
And our server entry point:
// src/entry-server.js
import { createSSRApp } from 'vue';
import App from './App.vue';
export function render() {
const app = createSSRApp(App);
// Here you'd typically do data fetching and hydration logic
return app.mount('#app'); // Vite's SSR hook will capture this output
}
When Vite builds for SSR, it creates ssr.js. Your Node.js server then imports this ssr.js file and calls the render function. The output from app.mount('#app') is HTML. This HTML is sent to the browser. The browser then loads the client-side JavaScript (built from index.html), which hydrates the existing HTML, making it interactive. The key is that createSSRApp is used on the server, and createApp (implicitly, when you build for client) is used on the client.
The magic happens in how Vite orchestrates this. It uses Rollup’s multi-entry point capability. You define your client entry (usually index.html) and your server entry (entry-server.js) within build.rollupOptions.input. Vite then intelligently builds two separate bundles. The client bundle is standard Vite. The server bundle, however, is configured to output an ES module (output.format: 'es') that can be imported by a Node.js server. Vite also automatically injects a hook into the server build that captures the output of app.mount() and makes it available to your server-side code.
The actual rendering logic on the server is minimal. You createSSRApp and mount it. Vite handles the rest: it captures the rendered HTML and provides it to your Node.js server. Your server then sends this HTML down to the client. On the client, Vite’s client-side build takes over, finds the SSR-rendered HTML, and attaches the necessary event listeners and state, effectively "hydrating" the static HTML into a live, interactive application. This avoids sending down the entire application code before rendering, leading to faster perceived load times.
What most developers miss is that the render() function in entry-server.js doesn’t return the HTML directly. Instead, Vite intercepts the side effect of app.mount('#app') during the server build process. The mount('#app') call on the server is designed to output HTML, not to attach to a DOM element that doesn’t exist. Vite’s SSR build environment captures this output. When your Node.js server imports the ssr.js bundle, it gets an object containing the exported render function. Executing this render function triggers the HTML generation.
The next hurdle you’ll often face is managing client-side state and data fetching between server and client.