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
- Key Challenges
- Core Concepts
- Detailed Problem Analysis & Solutions
- Architecture Patterns
- Best Practices & Lessons Learned
- 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_HOSTset 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_HOSTset at runtime (environment variable)
Why SSR?
- Security: Backend API completely hidden from browser
- Architecture: Frontend server acts as secure proxy/gateway
- Flexibility: Can add middleware, caching, request transformation
- SEO: Server-rendered HTML (bonus benefit)
Key Challenges
Challenge 1: "Network Error" on Login
Symptom: Login attempts failed with generic "Network Error"
Root Causes:
- Environment variable name mismatch
- Missing API proxy routes in SSR server
- 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:
- Fallback values in
nuxt.config.ts - Configuration errors failing silently
- Difficult to debug misconfiguration in production
Challenge 3: Runtime vs Build-time Configuration
Symptom: Environment variables not taking effect in SSR mode
Root Causes:
- Nuxt's runtime config requires specific environment variable naming
- Confusion between build-time and runtime configuration
- 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
/apiendpoints - 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:
- Nuxt's runtime config injection only works with
NUXT_PUBLIC_*prefix process.env.BACKEND_HOSTwasundefinedduring Docker runtime- Config fell back to hardcoded default
Solution Strategy (Generalized):
When working with framework runtime configuration:
- Check framework conventions for environment variable naming
- Support both patterns: Framework convention + custom names
- 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:
- Server renders page with
config.public.apiBase = "http://backend:8000" - This config is serialized into HTML:
window.__NUXT__.config.public.apiBase - Browser hydrates with server config → tries to call
http://backend:8000 - Browser DNS lookup fails (internal Docker name)
Solution Strategy (Generalized):
When building SSR applications with different network contexts:
- Identify the boundary: Where does client context differ from server?
- Use context detection:
import.meta.servervsimport.meta.client - Configure per-context: Different base URLs for different execution environments
- 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:
- Browser:
axios.post('/auth/token', data)→ relative URL - Nuxt Server: Catches
/auth/tokenviaserver/routes/auth/[...].ts - Proxy: Forwards to
http://backend:8000/auth/token(internal Docker network) - Backend: Processes request, returns response
- Nuxt Server: Returns response to browser
- 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:
- Local dev masking: Forgot to set
BACKEND_HOST? No problem, silently useslocalhost:8000 - Production confusion: Docker env var not set? Silently uses
localhost:8000(wrong!) - Debugging nightmare: App appears to work but calls wrong backend
- 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:
- Explicit over implicit: Require explicit configuration
- Fail loudly: Missing config should cause obvious errors
- Clear error messages: Tell user exactly what's missing
- 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:
- 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}`)
}
})
- 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.clientfor 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:
- Check environment variables
docker exec container_name env | grep BACKEND
docker exec container_name env | grep NUXT
- 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
- Check Nuxt runtime config
# Add temporary logging in nuxt.config.ts
console.log('API Base:', process.env.NUXT_PUBLIC_API_BASE)
- Inspect rendered HTML
curl http://localhost:8080/login | grep apiBase
# Look for: window.__NUXT__.config
- 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
- 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
- Understand SSR dual execution contexts (server vs client)
- Use framework conventions for environment variables
- Fail fast without defaults - explicit configuration
- Create proxy routes for all backend APIs
- Context-aware base URLs (full URL server, empty client)
- 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
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