Lovable + Vike SSR

    Lovable + Vike - Server-Side Rendering

    Add SSR to your Lovable-exported React application using Vike — without restructuring your existing code. Your React Router setup stays intact while gaining full SSR benefits.

    Node.js 18+
    15 Minutes Setup
    âš¡ Zero Restructuring

    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:

    Project Structure
    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.json
    1

    Install Dependencies

    Install packages
    npm install vike vike-react express compression serve-static
    npm install -D @types/express
    2

    Update vite.config.ts

    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,
      },
    });
    3

    Create Vike Wrapper Files

    pages/+config.ts

    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:

    pages/@path/+route.ts
    // Catch-all route - matches any URL path
    // Delegates routing to React Router instead of Vike filesystem routing
    export default '/*';

    pages/@path/+Page.tsx

    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:

    renderer/+Layout.tsx
    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>
      );
    }
    4

    Copy Your Routes to lib/RouterApp.tsx

    Copy the route definitions from your existing App.tsx into this file:

    lib/RouterApp.tsx
    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.

    5

    Create server.js

    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();
    6

    Update package.json Scripts

    package.json
    {
      "type": "module",
      "scripts": {
        "dev": "node server.js",
        "build": "vite build",
        "start": "NODE_ENV=production node server.js"
      }
    }
    7

    Create ecosystem.config.js

    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:

    1. 1Browser requests /admin
    2. 2Nginx proxies to Express:3000
    3. 3Express calls renderPage({ urlOriginal: '/admin' })
    4. 4Vike matches wildcard '/*' in pages/@path/+route.ts
    5. 5Vike renders pages/@path/+Page.tsx → <RouterApp />
    6. 6Layout wraps with <StaticRouter location="/admin">
    7. 7React Router matches /admin → renders <Admin />
    8. 8Server returns fully SSR'd HTML with admin content
    9. 9Client hydrates with <BrowserRouter>
    10. 10SPA navigation works normally

    Server Deployment

    Clone and setup
    cd /var/www
    git clone https://github.com/yourusername/your-lovable-app.git
    cd your-lovable-app
    npm install
    Build for production
    npm run build
    Create logs directory and start
    mkdir -p logs
    pm2 start ecosystem.config.js --env production
    pm2 save
    pm2 startup

    Nginx Configuration

    /etc/nginx/sites-available/yourdomain.com
    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:

    Enable site and SSL
    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.com

    Verify SSR is Working

    Check that the server returns rendered HTML, not an empty shell:

    Test SSR output
    curl -s http://localhost:3000/admin | head -30

    You should see actual content like <h1>Admin Dashboard</h1>, not just an empty <div id="root"></div>.

    PM2 Management Commands

    PM2 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 resources

    Advanced: 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.

    Ready to Deploy?

    Get a high-performance Cloud VPS for your Lovable SSR app.