Files
it-know-how/SSR_Migration_Learnings.md
T

26 KiB
Executable File

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

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):

// 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):

// 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:

# docker-compose.test.yml (WRONG)
environment:
  - BACKEND_HOST=http://backend:8000  # ❌ Not recognized by Nuxt at runtime
// 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:

// 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
  }
}
# docker-compose.yml (PRODUCTION)
environment:
  - NUXT_PUBLIC_API_BASE=http://backend:8000  # ✅ Auto-injected by Nuxt
# .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:

// app/plugins/http.client.ts (WRONG)
const http = axios.create({
  baseURL: config.public.apiBase  // "http://backend:8000"
})

When browser tried to call APIs:

// 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

// 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

// 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:

// 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:

# 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

// 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:

config.public.apiBase = undefined

When axios tries to use it:

axios.create({ baseURL: undefined })
// Makes requests to relative URLs
// If proxy not configured: 404 errors (obvious!)

Provide Example Configuration

# .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

# 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:
// 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}`)
  }
})
  1. With authentication injection:
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:

// 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:

// 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

// 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

# .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

## 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

// Bad: Magic values scattered in code
const apiUrl = 'http://localhost:8000'

Don't use different names for same concept

// Bad: Confusing naming
BACKEND_URL=...
API_ENDPOINT=...
SERVER_ADDRESS=...

// Good: Consistent naming
BACKEND_HOST=...

2. SSR Development

Do's

Always check execution context

// 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

// 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

// 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

// 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

// 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

# docker-compose.yml
services:
  frontend:
    environment:
      - NUXT_PUBLIC_API_BASE=http://backend:8000  # ✅ Service name
  backend:
    # Backend service

Separate build-time and runtime config

# 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

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

# Bad: Backend directly accessible
backend:
  ports:
    - "8000:8000"  # ❌ Defeats purpose of SSR proxy

Don't use localhost in containers

# 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
docker exec container_name env | grep BACKEND
docker exec container_name env | grep NUXT
  1. Verify network connectivity
# From frontend to backend
docker exec frontend_container wget -O- http://backend:8000/health

# Check if backend is running
docker logs backend_container
  1. Check Nuxt runtime config
# Add temporary logging in nuxt.config.ts
console.log('API Base:', process.env.NUXT_PUBLIC_API_BASE)
  1. Inspect rendered HTML
curl http://localhost:8080/login | grep apiBase
# Look for: window.__NUXT__.config
  1. 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
  1. Verify proxy routes are built
# 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

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

  • 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