Introduction
This guide shows how to add server-side rendering to your Lovable-exported React application using Vike — without restructuring your existing code. Your React Router setup stays intact while gaining full SSR benefits.
The key insight: Vike's wildcard route feature lets you delegate all routing to React Router while Vike handles the SSR. This means you can deploy in minutes, not hours.
SSR Benefits You'll Gain
- Full HTML content on initial load (not an empty <div id="root">)
- Search engines see your complete page content
- Faster First Contentful Paint and better Core Web Vitals
- Pages work with JavaScript disabled (initial view)
- SPA navigation still works after hydration
Prerequisites
- • Node.js 18.x or higher on your VPS
- • A Lovable project exported to GitHub
- • SSH access to your server
- • PM2 process manager (
npm install -g pm2)
Project Structure
You'll add just a few files to your existing Lovable project. Your src/ folder stays untouched:
your-lovable-app/
├── src/ # YOUR EXISTING CODE (unchanged)
│ ├── pages/
│ │ ├── Index.tsx
│ │ ├── Admin.tsx
│ │ ├── Dashboard.tsx
│ │ └── NotFound.tsx
│ ├── components/
│ ├── App.tsx # Your existing React Router setup
│ └── main.tsx
├── pages/ # NEW: Vike wrapper (4 files)
│ ├── +config.ts
│ └── @path/
│ ├── +route.ts # Wildcard catch-all
│ └── +Page.tsx # Renders RouterApp
├── renderer/
│ └── +Layout.tsx # SSR/Client router wrapper
├── lib/
│ └── RouterApp.tsx # Copy routes from App.tsx
├── server.js # Express server
├── vite.config.ts
├── ecosystem.config.js # PM2 config
└── package.jsonInstall Dependencies
npm install vike vike-react express compression serve-static
npm install -D @types/expressUpdate vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import vike from 'vike/plugin';
import path from 'path';
export default defineConfig({
plugins: [react(), vike()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
},
});Create Vike Wrapper Files
pages/+config.ts
import vikeReact from 'vike-react/config';
import type { Config } from 'vike/types';
export default {
extends: vikeReact,
} satisfies Config;pages/@path/+route.ts (The Key File)
This wildcard route captures all URLs and delegates routing to React Router:
// Catch-all route - matches any URL path
// Delegates routing to React Router instead of Vike filesystem routing
export default '/*';pages/@path/+Page.tsx
import RouterApp from '../../lib/RouterApp';
export default function Page() {
return <RouterApp />;
}renderer/+Layout.tsx
This layout handles the SSR/client router switching:
import React from 'react';
import { BrowserRouter, StaticRouter } from 'react-router-dom';
import { usePageContext } from 'vike-react/usePageContext';
function RouterWrapper({ children }: { children: React.ReactNode }) {
const pageContext = usePageContext();
const isServer = typeof window === 'undefined';
if (isServer) {
// SSR: StaticRouter doesn't use browser APIs
return (
<StaticRouter location={pageContext.urlPathname}>
{children}
</StaticRouter>
);
}
// Client: BrowserRouter for SPA navigation
return <BrowserRouter>{children}</BrowserRouter>;
}
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<React.StrictMode>
<RouterWrapper>
{children}
</RouterWrapper>
</React.StrictMode>
);
}Copy Your Routes to lib/RouterApp.tsx
Copy the route definitions from your existing App.tsx into this file:
import { Routes, Route } from 'react-router-dom';
// Import your existing page components
import Index from '../src/pages/Index';
import Admin from '../src/pages/Admin';
import AdminLogin from '../src/pages/AdminLogin';
import Dashboard from '../src/pages/Dashboard';
import Settings from '../src/pages/Settings';
import NotFound from '../src/pages/NotFound';
export default function RouterApp() {
return (
<Routes>
<Route path="/" element={<Index />} />
<Route path="/admin" element={<Admin />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
{/* Keep catch-all route last */}
<Route path="*" element={<NotFound />} />
</Routes>
);
}That's it! Your existing useNavigate(), useParams(), useLocation(), <Link>, and protected route logic all work unchanged.
Create server.js
import express from 'express';
import compression from 'compression';
import { renderPage } from 'vike/server';
import { createServer as createViteServer } from 'vite';
const isProduction = process.env.NODE_ENV === 'production';
const port = process.env.PORT || 3000;
async function startServer() {
const app = express();
app.use(compression());
if (isProduction) {
// Serve built assets
app.use(express.static('dist/client'));
} else {
// Dev: use Vite middleware
const vite = await createViteServer({
server: { middlewareMode: true },
});
app.use(vite.middlewares);
}
// SSR handler
app.get('*', async (req, res, next) => {
const pageContextInit = { urlOriginal: req.originalUrl };
const pageContext = await renderPage(pageContextInit);
if (pageContext.httpResponse) {
const { body, statusCode, headers } = pageContext.httpResponse;
headers.forEach(([name, value]) => res.setHeader(name, value));
res.status(statusCode).send(body);
} else {
next();
}
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
}
startServer();Update package.json Scripts
{
"type": "module",
"scripts": {
"dev": "node server.js",
"build": "vite build",
"start": "NODE_ENV=production node server.js"
}
}Create ecosystem.config.js
module.exports = {
apps: [{
name: 'lovable-ssr',
script: 'server.js',
instances: 'max',
exec_mode: 'cluster',
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
max_memory_restart: '500M',
error_file: './logs/err.log',
out_file: './logs/out.log',
}]
};How the SSR Request Flow Works
When a browser requests /admin:
- 1Browser requests
/admin - 2Nginx proxies to Express:3000
- 3Express calls
renderPage({ urlOriginal: '/admin' }) - 4Vike matches wildcard
'/*'in pages/@path/+route.ts - 5Vike renders pages/@path/+Page.tsx →
<RouterApp /> - 6Layout wraps with
<StaticRouter location="/admin"> - 7React Router matches /admin → renders
<Admin /> - 8Server returns fully SSR'd HTML with admin content
- 9Client hydrates with
<BrowserRouter> - 10SPA navigation works normally
Server Deployment
cd /var/www
git clone https://github.com/yourusername/your-lovable-app.git
cd your-lovable-app
npm installnpm run buildmkdir -p logs
pm2 start ecosystem.config.js --env production
pm2 save
pm2 startupNginx Configuration
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
gzip on;
gzip_types text/plain text/css application/json application/javascript;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /assets/ {
proxy_pass http://localhost:3000;
expires 1y;
add_header Cache-Control "public, immutable";
}
}Enable and secure:
sudo ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.comVerify SSR is Working
Check that the server returns rendered HTML, not an empty shell:
curl -s http://localhost:3000/admin | head -30You should see actual content like <h1>Admin Dashboard</h1>, not just an empty <div id="root"></div>.
PM2 Management Commands
pm2 list # View running processes
pm2 logs lovable-ssr # View logs
pm2 restart lovable-ssr # Restart
pm2 reload lovable-ssr # Zero-downtime reload
pm2 monit # Monitor resourcesAdvanced: Full Vike Migration (Optional)
For new projects or if you want Vike's native features (+data.ts, +guard.ts, automatic code splitting), you can use filesystem routing instead. This requires restructuring your pages into the pages/ directory with Vike conventions. See the Vike documentation for details.
Troubleshooting
Hydration Mismatch Warnings
Ensure browser-only code is in useEffect or guarded with typeof window !== 'undefined'.
Routes Not Matching
Verify your routes in lib/RouterApp.tsx match your original App.tsx exactly.
Static Assets 404
Ensure express.static points to 'dist/client' and assets use the /assets/ path prefix.
