# SSR Migration Learnings: From Static Site to Server-Side Rendering ## Overview This document captures the challenges, learnings, and solutions from migrating a Nuxt frontend application from static site generation (SSG) to server-side rendering (SSR) mode, particularly focusing on backend API communication in a Docker/containerized environment. ## Table of Contents - [Background Context](#background-context) - [Key Challenges](#key-challenges) - [Core Concepts](#core-concepts) - [Detailed Problem Analysis & Solutions](#detailed-problem-analysis--solutions) - [Architecture Patterns](#architecture-patterns) - [Best Practices & Lessons Learned](#best-practices--lessons-learned) - [Future Reference](#future-reference) --- ## Background Context ### Initial State (Static Site Generation) - **Architecture**: Nuxt app built with `npm run generate` → static HTML/CSS/JS files - **Serving**: Nginx serving static files directly - **API Communication**: Browser → Nginx `/api/*` → Backend service - **Configuration**: `BACKEND_HOST` set at **build time** (baked into static files) ### Target State (Server-Side Rendering) - **Architecture**: Nuxt app running as Node.js server (Nitro) - **Serving**: Nginx → Nuxt SSR server (port 3000) → Backend service - **API Communication**: Browser → Nuxt Server (internal) → Backend service - **Configuration**: `BACKEND_HOST` set at **runtime** (environment variable) ### Why SSR? 1. **Security**: Backend API completely hidden from browser 2. **Architecture**: Frontend server acts as secure proxy/gateway 3. **Flexibility**: Can add middleware, caching, request transformation 4. **SEO**: Server-rendered HTML (bonus benefit) --- ## Key Challenges ### Challenge 1: "Network Error" on Login **Symptom**: Login attempts failed with generic "Network Error" **Root Causes**: 1. Environment variable name mismatch 2. Missing API proxy routes in SSR server 3. Client trying to access internal Docker network addresses ### Challenge 2: Hidden Default Values **Symptom**: Hard-coded `http://localhost:8000` defaults masked configuration errors **Root Causes**: 1. Fallback values in `nuxt.config.ts` 2. Configuration errors failing silently 3. Difficult to debug misconfiguration in production ### Challenge 3: Runtime vs Build-time Configuration **Symptom**: Environment variables not taking effect in SSR mode **Root Causes**: 1. Nuxt's runtime config requires specific environment variable naming 2. Confusion between build-time and runtime configuration 3. SSR hydration passing wrong values to client --- ## Core Concepts ### 1. Nuxt Runtime Configuration #### Build-time vs Runtime **Build-time** (Static Generation): ```typescript // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { public: { apiBase: process.env.BACKEND_HOST || 'http://localhost:8000' } } }) ``` - Value read **during `npm run build`** - Baked into compiled JavaScript - **Cannot change** without rebuilding **Runtime** (SSR Mode): ```typescript // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { public: { apiBase: process.env.NUXT_PUBLIC_API_BASE || process.env.BACKEND_HOST } } }) ``` - Value read **when server starts** - Can be overridden via environment variables - **Can change** without rebuilding (just restart container) #### Nuxt Environment Variable Naming Convention Nuxt automatically injects environment variables into runtime config if they follow specific patterns: | Runtime Config Path | Auto-injected Env Var | Manual Env Var | |---------------------|----------------------|----------------| | `runtimeConfig.public.apiBase` | `NUXT_PUBLIC_API_BASE` | `process.env.BACKEND_HOST` | | `runtimeConfig.secret` | `NUXT_SECRET` | `process.env.SECRET` | | `runtimeConfig.public.foo.bar` | `NUXT_PUBLIC_FOO_BAR` | `process.env.FOO_BAR` | **Key Insight**: Using `NUXT_PUBLIC_*` prefix enables automatic runtime override without code changes. ### 2. SSR Request Flow #### Traditional SSG (Static Site) ``` ┌─────────┐ ┌───────┐ ┌─────────┐ │ Browser │─────▶│ Nginx │─────▶│ Backend │ └─────────┘ └───────┘ └─────────┘ /api/* → proxy ``` - Browser sees `/api` endpoints - Nginx proxies to backend - CORS required if different origins #### Modern SSR Pattern ``` ┌─────────┐ ┌───────┐ ┌────────────┐ ┌─────────┐ │ Browser │─────▶│ Nginx │─────▶│ Nuxt SSR │─────▶│ Backend │ └─────────┘ └───────┘ │ (Node.js) │ └─────────┘ └────────────┘ Proxy routes ``` - Browser never sees backend - Nuxt server makes backend calls - No CORS needed (server-to-server) ### 3. Client vs Server Context in SSR In Nuxt SSR, code runs in **two contexts**: #### Server Context - Runs on Node.js server - Has access to Docker internal network - Can reach `http://backend:8000` - No browser APIs (no `window`, `localStorage`, etc.) #### Client Context - Runs in browser - Only sees public internet/nginx - **Cannot** reach `http://backend:8000` (internal Docker address) - Has browser APIs **Critical Insight**: axios `baseURL` must be different for server vs client! --- ## Detailed Problem Analysis & Solutions ### Problem 1: Environment Variable Configuration #### Issue: NUXT_PUBLIC_API_BASE vs BACKEND_HOST **What Happened**: ```yaml # docker-compose.test.yml (WRONG) environment: - BACKEND_HOST=http://backend:8000 # ❌ Not recognized by Nuxt at runtime ``` ```typescript // nuxt.config.ts runtimeConfig: { public: { apiBase: process.env.BACKEND_HOST // Read at build time, not runtime! } } ``` **Why It Failed**: 1. Nuxt's runtime config injection only works with `NUXT_PUBLIC_*` prefix 2. `process.env.BACKEND_HOST` was `undefined` during Docker runtime 3. Config fell back to hardcoded default **Solution Strategy** (Generalized): When working with framework runtime configuration: 1. **Check framework conventions** for environment variable naming 2. **Support both patterns**: Framework convention + custom names 3. **Fail fast** without defaults to catch configuration errors early **Our Implementation**: ```typescript // nuxt.config.ts runtimeConfig: { public: { // NUXT_PUBLIC_API_BASE auto-injected by Nuxt // BACKEND_HOST read explicitly for local dev apiBase: process.env.NUXT_PUBLIC_API_BASE || process.env.BACKEND_HOST } } ``` ```yaml # docker-compose.yml (PRODUCTION) environment: - NUXT_PUBLIC_API_BASE=http://backend:8000 # ✅ Auto-injected by Nuxt ``` ```bash # .env (LOCAL DEVELOPMENT) BACKEND_HOST=http://localhost:8000 # ✅ Simpler name for developers ``` **Key Takeaway**: Use framework conventions in production, but provide developer-friendly alternatives for local dev. --- ### Problem 2: Client-Side API Calls in SSR #### Issue: Browser Cannot Reach Internal Docker Network **What Happened**: ```typescript // app/plugins/http.client.ts (WRONG) const http = axios.create({ baseURL: config.public.apiBase // "http://backend:8000" }) ``` When browser tried to call APIs: ```javascript // Browser console POST http://backend:8000/auth/token // ❌ DNS_PROBE_FINISHED_NXDOMAIN // Backend is a Docker service name, not resolvable from browser! ``` **Root Cause Analysis**: SSR hydration works like this: 1. Server renders page with `config.public.apiBase = "http://backend:8000"` 2. This config is serialized into HTML: `window.__NUXT__.config.public.apiBase` 3. Browser hydrates with server config → tries to call `http://backend:8000` 4. Browser DNS lookup fails (internal Docker name) **Solution Strategy** (Generalized): When building SSR applications with different network contexts: 1. **Identify the boundary**: Where does client context differ from server? 2. **Use context detection**: `import.meta.server` vs `import.meta.client` 3. **Configure per-context**: Different base URLs for different execution environments 4. **Create proxy routes**: Let server handle external communication **Our Implementation**: #### Part 1: Context-Aware axios Configuration ```typescript // app/plugins/http.client.ts export default defineNuxtPlugin(() => { const config = useRuntimeConfig() // Different baseURL based on execution context const apiBase = import.meta.server ? config.public.apiBase // Server: "http://backend:8000" : '' // Client: "" (relative URLs) const http = axios.create({ baseURL: apiBase, withCredentials: false, }) return { provide: { http } } }) ``` #### Part 2: Server-Side API Proxy ```typescript // server/routes/auth/[...].ts // Catch-all route: matches /auth/*, /auth/token, /auth/me, etc. export default defineEventHandler(async (event) => { const config = useRuntimeConfig() // event.path = "/auth/token" // backendUrl = "http://backend:8000/auth/token" const backendUrl = `${config.public.apiBase}${event.path}` // Proxy request to backend return proxyRequest(event, backendUrl) }) ``` **Request Flow**: 1. **Browser**: `axios.post('/auth/token', data)` → relative URL 2. **Nuxt Server**: Catches `/auth/token` via `server/routes/auth/[...].ts` 3. **Proxy**: Forwards to `http://backend:8000/auth/token` (internal Docker network) 4. **Backend**: Processes request, returns response 5. **Nuxt Server**: Returns response to browser 6. **Browser**: Receives response as if it came from same origin **Key Insight**: In SSR, the client should **never** make external API calls directly. All external communication goes through the SSR server. --- ### Problem 3: Hardcoded Defaults Hiding Configuration Errors #### Issue: Silent Failures **What Happened**: ```typescript // nuxt.config.ts (WRONG) runtimeConfig: { public: { apiBase: process.env.BACKEND_HOST || 'http://localhost:8000' // ❌ Silent fallback } } ``` **Why This Is Problematic**: 1. **Local dev masking**: Forgot to set `BACKEND_HOST`? No problem, silently uses `localhost:8000` 2. **Production confusion**: Docker env var not set? Silently uses `localhost:8000` (wrong!) 3. **Debugging nightmare**: App appears to work but calls wrong backend 4. **No visibility**: Developers unaware of misconfiguration **Real-World Scenario**: ```bash # Developer forgot to copy .env file $ npm run dev # App starts fine, uses hardcoded localhost:8000 # Works... but by accident! # Later in production: $ docker-compose up # NUXT_PUBLIC_API_BASE not set in docker-compose # Falls back to localhost:8000 # Frontend tries to call localhost inside container → fails # But error is unclear: "Network Error" ``` **Solution Strategy** (Generalized): **Fail Fast Principle**: 1. **Explicit over implicit**: Require explicit configuration 2. **Fail loudly**: Missing config should cause obvious errors 3. **Clear error messages**: Tell user exactly what's missing 4. **Documentation**: Example files with clear instructions **Our Implementation**: #### Remove Defaults ```typescript // nuxt.config.ts (CORRECT) runtimeConfig: { public: { apiBase: process.env.NUXT_PUBLIC_API_BASE || process.env.BACKEND_HOST // No || 'http://localhost:8000' fallback! } } ``` Now if neither env var is set: ```javascript config.public.apiBase = undefined ``` When axios tries to use it: ```javascript axios.create({ baseURL: undefined }) // Makes requests to relative URLs // If proxy not configured: 404 errors (obvious!) ``` #### Provide Example Configuration ```bash # .env.example # REQUIRED: Backend API base URL # # For local development: # BACKEND_HOST=http://localhost:8000 # # IMPORTANT: You must create a .env file: # cp .env.example .env # BACKEND_HOST=http://localhost:8000 ``` #### Update Documentation ```markdown # README.md **IMPORTANT:** `BACKEND_HOST` is **required** and must be set via `.env` file. There is no default value. **Local development:** Create a `.env` file from the example: ```bash cp .env.example .env ``` ``` **Benefits**: - ✅ Configuration errors obvious immediately - ✅ Developers forced to understand configuration - ✅ Production deployments fail fast if misconfigured - ✅ Clear documentation guides proper setup **Key Takeaway**: Defaults are tempting but dangerous. Explicit configuration prevents silent failures. --- ## Architecture Patterns ### Pattern 1: SSR API Proxy Gateway **Use Case**: Frontend needs to call backend API, but backend should be hidden from browser **Implementation**: ```typescript // server/routes/[api]/[...].ts // Generic catch-all for any API prefix export default defineEventHandler(async (event) => { const config = useRuntimeConfig() // Extract path: /api/users/123 → /users/123 const path = event.path.replace(/^\/api/, '') // Build backend URL const backendUrl = `${config.public.apiBase}${path}` // Forward everything: method, headers, body, query params return proxyRequest(event, backendUrl) }) ``` **Benefits**: - 🔒 Backend URL completely hidden from browser - 🛡️ Can add authentication, rate limiting, caching in proxy - 🔧 Easy to add request/response transformation - 📊 Centralized logging of all API calls **Variations**: 1. **Multiple backends**: ```typescript // Route different prefixes to different backends export default defineEventHandler(async (event) => { const config = useRuntimeConfig() if (event.path.startsWith('/api/auth')) { return proxyRequest(event, `${config.authService}${event.path}`) } else if (event.path.startsWith('/api/data')) { return proxyRequest(event, `${config.dataService}${event.path}`) } }) ``` 2. **With authentication injection**: ```typescript export default defineEventHandler(async (event) => { const config = useRuntimeConfig() const backendUrl = `${config.public.apiBase}${event.path}` // Add server-side auth token return proxyRequest(event, backendUrl, { headers: { 'Authorization': `Bearer ${config.serverApiToken}` } }) }) ``` ### Pattern 2: Context-Aware Plugin Configuration **Use Case**: Plugin behavior differs between server and client contexts **Implementation**: ```typescript // plugins/http.client.ts export default defineNuxtPlugin(() => { const config = useRuntimeConfig() // Server context: full backend URL (internal network) // Client context: empty (relative URLs to same origin) const baseURL = import.meta.server ? config.public.apiBase : '' const http = axios.create({ baseURL }) // Client-only: add auth from localStorage if (import.meta.client) { http.interceptors.request.use((req) => { const token = localStorage.getItem('access_token') if (token) { req.headers.Authorization = `Bearer ${token}` } return req }) } return { provide: { http } } }) ``` **Key Points**: - Use `import.meta.server` / `import.meta.client` for context detection - Server context: can access Docker internal network - Client context: browser APIs available (localStorage, etc.) ### Pattern 3: Environment Variable Layering **Use Case**: Support multiple deployment environments with different naming conventions **Strategy**: ```typescript // nuxt.config.ts export default defineNuxtConfig({ runtimeConfig: { public: { // Priority order (first defined wins): // 1. NUXT_PUBLIC_API_BASE (Docker production, Nuxt convention) // 2. BACKEND_HOST (local dev, CI/CD, simpler name) // 3. undefined (fail fast) apiBase: process.env.NUXT_PUBLIC_API_BASE || process.env.BACKEND_HOST } } }) ``` **Usage Patterns**: | Environment | Variable Used | Reason | |-------------|---------------|--------| | Local Dev | `BACKEND_HOST=http://localhost:8000` | Simpler for developers | | Docker Compose | `NUXT_PUBLIC_API_BASE=http://backend:8000` | Nuxt convention, runtime override | | CI/CD Build | `BACKEND_HOST=http://mock:8000` | Test environment | | Production | `NUXT_PUBLIC_API_BASE=http://api-service:8000` | Kubernetes service name | --- ## Best Practices & Lessons Learned ### 1. Configuration Management #### ✅ Do's **Always fail fast without defaults** ```typescript // Good apiBase: process.env.BACKEND_HOST // undefined if not set // Bad apiBase: process.env.BACKEND_HOST || 'http://localhost:8000' // Silent fallback ``` **Provide clear example files** ```bash # .env.example with EXTENSIVE comments # Explain why each variable is needed # Show example values for different environments BACKEND_HOST=http://localhost:8000 # Local dev # BACKEND_HOST=http://backend:8000 # Docker ``` **Document required vs optional** ```markdown ## Required Environment Variables - `BACKEND_HOST`: Backend API base URL (REQUIRED) - `DATABASE_URL`: Database connection string (REQUIRED) ## Optional Environment Variables - `LOG_LEVEL`: Logging verbosity (default: 'info') ``` #### ❌ Don'ts **Don't hide configuration in code** ```typescript // Bad: Magic values scattered in code const apiUrl = 'http://localhost:8000' ``` **Don't use different names for same concept** ```typescript // Bad: Confusing naming BACKEND_URL=... API_ENDPOINT=... SERVER_ADDRESS=... // Good: Consistent naming BACKEND_HOST=... ``` ### 2. SSR Development #### ✅ Do's **Always check execution context** ```typescript // Server-only code if (import.meta.server) { // Database queries, file system access } // Client-only code if (import.meta.client) { // localStorage, window APIs, browser events } ``` **Use relative URLs in client** ```typescript // Good: Let SSR server handle routing axios.get('/api/users') // Bad: Absolute URLs bypass SSR benefits axios.get('http://backend:8000/api/users') ``` **Create comprehensive proxy routes** ```typescript // server/routes/auth/[...].ts - Handles /auth/* // server/routes/api/[...].ts - Handles /api/* // server/routes/data/[...].ts - Handles /data/* ``` #### ❌ Don'ts **Don't access browser APIs in server context** ```typescript // Bad: Crashes on server const token = localStorage.getItem('token') // Good: Guard with context check const token = import.meta.client ? localStorage.getItem('token') : null ``` **Don't expose internal URLs to client** ```typescript // Bad: Client gets internal Docker URL const config = { apiBase: 'http://backend:8000' // Browser can't resolve this! } // Good: Client uses relative URLs const apiBase = import.meta.server ? 'http://backend:8000' : '' ``` ### 3. Docker & Containerization #### ✅ Do's **Use service names for internal communication** ```yaml # docker-compose.yml services: frontend: environment: - NUXT_PUBLIC_API_BASE=http://backend:8000 # ✅ Service name backend: # Backend service ``` **Separate build-time and runtime config** ```dockerfile # Build stage - no environment variables needed FROM node:25-alpine AS build COPY . . RUN npm run build # Runtime stage - environment variables matter here FROM node:25-alpine AS runtime ENV NODE_ENV=production CMD ["node", "server/index.mjs"] ``` **Expose only what's necessary** ```yaml services: backend: expose: - "8000" # ✅ Only internal Docker network # NO ports: section - not accessible from host frontend: expose: - "3000" # ✅ Only to nginx # NO ports: section nginx: ports: - "80:80" # ✅ Only nginx exposed to host ``` #### ❌ Don'ts **Don't expose internal services to host** ```yaml # Bad: Backend directly accessible backend: ports: - "8000:8000" # ❌ Defeats purpose of SSR proxy ``` **Don't use localhost in containers** ```yaml # Bad: localhost inside container is the container itself environment: - BACKEND_HOST=http://localhost:8000 # ❌ Wrong! # Good: Use service names environment: - BACKEND_HOST=http://backend:8000 # ✅ Correct ``` ### 4. Debugging SSR Issues #### Diagnostic Checklist When facing "Network Error" or similar issues: 1. **Check environment variables** ```bash docker exec container_name env | grep BACKEND docker exec container_name env | grep NUXT ``` 2. **Verify network connectivity** ```bash # From frontend to backend docker exec frontend_container wget -O- http://backend:8000/health # Check if backend is running docker logs backend_container ``` 3. **Check Nuxt runtime config** ```bash # Add temporary logging in nuxt.config.ts console.log('API Base:', process.env.NUXT_PUBLIC_API_BASE) ``` 4. **Inspect rendered HTML** ```bash curl http://localhost:8080/login | grep apiBase # Look for: window.__NUXT__.config ``` 5. **Check axios requests in browser DevTools** ``` Network tab → Look at request URLs - Relative URLs (e.g., /auth/token) → ✅ Good - Internal URLs (e.g., http://backend:8000) → ❌ Problem ``` 6. **Verify proxy routes are built** ```bash # Check Nuxt build output ls -la .output/server/chunks/routes/ # Should see auth/_..._.mjs or similar ``` #### Common Error Patterns | Error | Likely Cause | Solution | |-------|--------------|----------| | `DNS_PROBE_FINISHED_NXDOMAIN` | Client trying to reach internal Docker name | Check axios baseURL, add proxy route | | `Network Error` (generic) | Multiple possible causes | Check all diagnostics above | | `ECONNREFUSED` | Service not running or wrong port | Verify backend is up, check ports | | `Not authenticated` (401) | Proxy working, auth issue | Check credentials, token handling | | `Cannot find module` | Build issue | Rebuild without cache | --- ## Future Reference ### Quick Decision Tree ```mermaid graph TD A[Need backend API in frontend?] -->|Yes| B[SSR or SSG?] B -->|SSR| C[Use proxy pattern] B -->|SSG| D[Direct calls with CORS] C --> E[Create server/routes proxy] C --> F[Use empty baseURL in client] C --> G[Use internal URL in server] D --> H[Configure CORS on backend] D --> I[Use full backend URL everywhere] ``` ### SSR vs SSG Decision Matrix | Factor | SSR | SSG | |--------|-----|-----| | **Backend Hidden** | ✅ Yes | ❌ No (exposed to browser) | | **Server Resources** | Higher (Node.js) | Lower (static files) | | **Configuration** | Runtime env vars | Build-time vars | | **SEO** | ✅ Excellent | ✅ Excellent | | **Dynamic Content** | ✅ Real-time | ❌ Build-time only | | **Deploy Complexity** | Medium (Node.js server) | Low (just files) | | **Security** | ✅ Backend completely hidden | ⚠️ Backend URL exposed | | **Caching** | Complex (server-side) | Simple (CDN) | **Choose SSR when**: - Backend must be hidden from browser - Need server-side request transformation - Real-time content crucial - Middleware/authentication needed **Choose SSG when**: - Content mostly static - Backend can be public (with CORS) - Want simplest deployment - Need maximum performance (CDN) ### Environment Variable Naming Conventions | Framework | Public Runtime Config | Private Runtime Config | |-----------|----------------------|------------------------| | **Nuxt 3/4** | `NUXT_PUBLIC_*` | `NUXT_*` | | **Next.js** | `NEXT_PUBLIC_*` | (none - server-side only) | | **Vite** | `VITE_*` | (none - build-time only) | | **Create React App** | `REACT_APP_*` | (none - build-time only) | **Key Point**: Most frameworks require prefixes for public runtime config auto-injection. ### File Structure Reference ``` project/ ├── app/ │ ├── plugins/ │ │ └── http.client.ts # Context-aware axios setup │ ├── composables/ │ │ └── useAuth.ts # Uses $http from plugin │ └── pages/ │ └── login.vue # Makes API calls ├── server/ │ └── routes/ │ └── auth/ │ └── [...].ts # Proxy for /auth/* endpoints ├── .env.example # Template with docs ├── .env # Local config (gitignored) ├── nuxt.config.ts # Runtime config setup ├── docker-compose.yml # Production config └── tests/deployment/ ├── docker-compose.test.yml # Test deployment └── nginx.test.conf # Nginx config ``` --- ## Summary ### Critical Success Factors 1. **Understand SSR dual execution contexts** (server vs client) 2. **Use framework conventions** for environment variables 3. **Fail fast** without defaults - explicit configuration 4. **Create proxy routes** for all backend APIs 5. **Context-aware base URLs** (full URL server, empty client) 6. **Document everything** - future you will thank you ### Final Checklist for SSR Migration - [ ] Remove all hardcoded defaults from config - [ ] Support both framework and custom env var names - [ ] Create server proxy routes for all API endpoints - [ ] Configure axios baseURL based on execution context - [ ] Update docker-compose with proper env vars - [ ] Test both client-side and server-side API calls - [ ] Document required environment variables - [ ] Provide .env.example with clear instructions - [ ] Update README with SSR-specific instructions - [ ] Verify backend is not exposed to browser (network tab) ### Key Learnings > **The most important insight**: In SSR, the frontend runs in TWO places (server and browser), and each place has different network access. Configuration must account for both contexts. > **The second most important insight**: Explicit configuration with no defaults prevents entire classes of bugs. The inconvenience of setup is far outweighed by the clarity and reliability gained. > **The third most important insight**: Framework conventions exist for good reasons. Use `NUXT_PUBLIC_*` for Nuxt, `NEXT_PUBLIC_*` for Next.js, etc. Don't fight the framework - learn its patterns. --- ## Additional Resources ### Documentation Links - [Nuxt 3 Runtime Config](https://nuxt.com/docs/guide/going-further/runtime-config) - [Nuxt 3 Server Routes](https://nuxt.com/docs/guide/directory-structure/server) - [h3 Event Handlers](https://h3.unjs.io/guide/event-handler) - [Docker Compose Environment Variables](https://docs.docker.com/compose/environment-variables/) ### Related Patterns - API Gateway Pattern - Backend for Frontend (BFF) - Proxy Pattern - Configuration as Code --- *Document created: 2026-04-16* *Last updated: 2026-04-16* *Context: SSR migration from static site to server-side rendering*