Chapter 32

SSR, Nuxt 3 and Production: Hydration, Caching Strategy and CI/CD Pipeline

Chapter 32: SSR, Nuxt 3, and Production Deployment — Hydration Mechanics, Caching Strategy, and CI/CD Pipeline

SSR hydration is not "re-rendering" — it is associating Vue's VNode tree with the actual DOM nodes the server generated and binding event listeners. Because it skips DOM creation, hydration is 3–10x faster than rendering from scratch. But a hydration mismatch (Hydration Mismatch) forces the browser to rebuild the entire DOM tree — an operation more expensive than not using SSR at all.

The central question of this chapter: How does the SSR hydration mechanism work, how do you choose among Nuxt 3's three rendering modes, and how do you build a fully-automated CI/CD pipeline from code commit to production deployment?

After reading this chapter you will understand:


Level 1 · What You Need to Know (1–3 Years Experience)

1.1 The Complete SSR Workflow

Server-Side Rendering (SSR) works in two phases:

Phase 1: Server generates HTML

User request → Node.js server
             → Create Vue app instance
             → Execute setup() (fetch data)
             → Render to HTML string (@vue/server-renderer)
             → Return complete HTML page to browser

Phase 2: Client takes over (Hydration)

Browser receives HTML → Immediately displays page content (user sees the page)
                     → Downloads JavaScript
                     → Vue "hydrates" on the client: associates VNodes with existing DOM
                     → Binds event listeners
                     → Page becomes interactive

Why use SSR?

Feature CSR (Client-Side Rendering) SSR
First paint speed Slow (must wait for JS download + execution) Fast (HTML displays immediately)
SEO Poor (crawlers can't see content) Good (HTML contains content)
Server load Low High
Time before interaction JS download + parse + render JS download + hydration

1.2 The True Nature of Hydration: Association, Not Re-rendering

Wrong mental model: Many people think hydration means "the client renders everything again."

Correct understanding: Hydration pairs Vue's virtual DOM tree with the real DOM nodes the server generated — one VNode to one DOM node — and then binds event listeners to those DOM nodes. No new DOM nodes are created.

DOM generated by the server:
<div id="app">
  <h1>Hello Vue SSR</h1>         ← Real DOM node (already in browser)
  <button>Click me</button>       ← Real DOM node (already in browser)
</div>

During Vue hydration:
VNode { tag: 'div', ... }    → associates with → <div id="app"> (existing DOM)
VNode { tag: 'h1', ... }     → associates with → <h1> (existing DOM)
VNode { tag: 'button', ... } → associates with → <button> (existing DOM)
                                                   ↳ binds @click event listener

This is why hydration is faster than rendering from scratch: document.createElement() (creating DOM nodes) is far more expensive than querySelector() (finding existing DOM nodes).

1.3 Hydration Mismatch: The Most Dangerous SSR Trap

When the HTML generated by the server doesn't match the VNode structure the client expects, a "Hydration Mismatch" occurs. Vue 3 outputs a warning in development mode and forces a full re-render (discards the server HTML, recreates the DOM):

[Vue warn]: Hydration node mismatch:
- Client vnode: div
- Server rendered DOM: span

5 common root causes of hydration mismatches:

Root cause 1: Using Date.now() or Math.random()

<script setup>
// Server: Date.now() = 1700000000000
// Client: Date.now() = 1700000000123 (different!)
const timestamp = ref(Date.now());
</script>
<template>
  <div>{{ timestamp }}</div>  <!-- MISMATCH! -->
</template>

Root cause 2: Using browser-only APIs

<script setup>
// window doesn't exist in Node.js — undefined on server, real value on client
const screenWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 0);
</script>

Root cause 3: Incorrect use of v-if conditions

<script setup>
// Server: isLoggedIn = true (from request cookie)
// Client at hydration time: isLoggedIn = false (hasn't read localStorage yet)
const isLoggedIn = ref(false);
onMounted(() => {
  isLoggedIn.value = !!localStorage.getItem('token');
});
</script>
<template>
  <!-- Server rendered "Welcome back", client expects "Please log in" → MISMATCH -->
  <div v-if="isLoggedIn">Welcome back</div>
  <div v-else>Please log in</div>
</template>

Root cause 4: Timezone differences

<!-- Server (UTC+8): 2024-01-01 08:00 -->
<!-- Client (UTC-5): 2023-12-31 19:00 -->
<div>{{ new Date().toLocaleDateString() }}</div>

Root cause 5: Randomized CSS class names (some CSS-in-JS libraries)

CSS-in-JS libraries may generate different class name hashes (like sc-abc123) on the server vs. the client, causing attribute mismatches.

1.4 Solutions for Hydration Mismatches

Solution 1: Wrap client-only content with <ClientOnly>

<template>
  <!-- Server skips this; only the client renders it -->
  <ClientOnly>
    <div>Current time: {{ currentTime }}</div>
    <template #fallback>
      <!-- Placeholder shown by the server (optional) -->
      <div>Loading...</div>
    </template>
  </ClientOnly>
</template>

Solution 2: Update data in onMounted

<script setup>
const isClient = ref(false);
const screenWidth = ref(1920); // server-safe default value

onMounted(() => {
  isClient.value = true;
  screenWidth.value = window.innerWidth; // onMounted only runs on the client
});
</script>

<template>
  <div>{{ isClient ? screenWidth : 'Loading' }}</div>
</template>

1.5 Nuxt 3's Three Rendering Modes

Nuxt 3 supports three rendering modes configurable at the page level:

Mode 1: Full SSR (default)

// nuxt.config.ts
export default defineNuxtConfig({
  ssr: true  // default — all pages are SSR
});

Mode 2: Pure CSR (SSR disabled)

export default defineNuxtConfig({
  ssr: false  // all pages render on the client (like a Vite SPA)
});

Mode 3: Hybrid rendering (routeRules — most flexible)

// nuxt.config.ts
export default defineNuxtConfig({
  ssr: true,
  routeRules: {
    // Home page: pre-rendered (HTML generated at build time, like SSG)
    '/': { prerender: true },
    
    // Product list: SSR + server cache (CDN caches for 60 seconds)
    '/products': { swr: 60 },   // Stale-While-Revalidate
    
    // User dashboard: CSR (no SSR — it's private, personalized content)
    '/dashboard/**': { ssr: false },
    
    // Static docs: pre-rendered + permanent cache
    '/docs/**': { 
      prerender: true,
      headers: { 'cache-control': 'max-age=31536000, immutable' }
    },
  }
});

1.6 useFetch vs $fetch

This is one of the most common points of confusion in Nuxt 3:

useFetch (SSR-friendly):

<script setup>
// useFetch executes the request on the server, serializes data into the HTML's __NUXT_DATA__
// During client hydration, this data is used directly — no duplicate network request
const { data, pending, error } = await useFetch('/api/products');
</script>

<template>
  <div v-for="product in data" :key="product.id">
    {{ product.name }}
  </div>
</template>

$fetch (client-side requests):

<script setup>
// $fetch only runs on the client — doesn't participate in SSR data passing
const products = ref([]);

onMounted(async () => {
  products.value = await $fetch('/api/products');
});
</script>

Choosing between them:

Scenario Use
Data that needs SEO useFetch (data goes into HTML)
Content that must appear on first screen useFetch (doesn't wait for JS to load)
Requests triggered by user interaction $fetch (concise and flexible)
Private data after login $fetch (client request, not in HTML)

Level 2 · How It Actually Works (3–5 Years Experience)

2.1 Nuxt 3's Data Transfer Mechanism

When useFetch executes on the server, Nuxt 3 transfers data from server to client through this flow:

Server:
1. Executes useFetch('/api/products')
2. Stores data in Nuxt payload (useNuxtApp().payload)
3. Serializes payload to JSON
4. Injects into HTML in a <script> tag:
   <script type="application/json" id="__NUXT_DATA__">
     { "products": [...] }
   </script>

Client:
1. During Vue hydration, detects that useNuxtApp().payload already has data
2. useFetch returns payload data directly — no network request
3. Reactive data is identical to server data → no hydration mismatch

This is why useFetch is SSR-friendly: server and client use exactly the same data, so inconsistency is impossible.

2.2 Cache Strategy: Correct Cache-Control Configuration

Production caching strategy directly impacts user experience and server load.

Key principles:

index.html → Never cache (Cache-Control: no-store)
  ↳ Reason: Users must always get the latest HTML, which contains
             references to the latest hashed filenames

Hashed assets → Cache permanently (Cache-Control: max-age=31536000, immutable)
  ↳ Reason: Filename includes content hash (e.g. main.8f3a2b.js)
             When content changes, filename changes too
             No manual cache invalidation needed

API responses → Short-term cache as appropriate
  ↳ Example: Cache-Control: max-age=60, stale-while-revalidate=3600

Nginx configuration example:

server {
  listen 80;
  root /usr/share/nginx/html;
  
  # index.html: no caching whatsoever
  location = /index.html {
    add_header Cache-Control "no-store, no-cache, must-revalidate";
    add_header Pragma "no-cache";
  }
  
  # Hashed assets (JS/CSS/fonts): permanent cache
  # These filenames contain content hashes — permanent caching is safe
  location ~* \.(js|css|woff2|woff|ttf|eot)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    gzip_static on;
  }
  
  # Images: long-term cache (but not permanent — filenames may not change)
  location ~* \.(png|jpg|jpeg|gif|webp|avif|svg|ico)$ {
    add_header Cache-Control "public, max-age=2592000"; # 30 days
  }
  
  # HTML5 History routing for SPA
  location / {
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-store";
  }
}

stale-while-revalidate strategy (suitable for Nuxt SSR API caching):

Cache-Control: max-age=60, stale-while-revalidate=3600

This means:

2.3 Multi-Stage Docker Build: From 800MB to 25MB

The key to production Docker images is multi-stage build, separating the build environment from the runtime environment:

# ===== Stage 1: Build =====
FROM node:20-alpine AS builder
# Full node:20-alpine is ~170MB
# Adding node_modules can reach 500–800MB

WORKDIR /app

# Copy package files first (leverage Docker layer caching)
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile  # precise installation

# Copy source code and build
COPY . .
RUN npm run build

# ===== Stage 2: Runtime (SPA static files) =====
FROM nginx:alpine AS runner
# nginx:alpine is only ~25MB

# Copy Nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf

# Copy only the build output from the builder stage (NOT node_modules)
COPY --from=builder /app/dist /usr/share/nginx/html

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

For Nuxt 3 SSR (requires a Node.js runtime):

# ===== Stage 1: Install dependencies =====
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

# ===== Stage 2: Build =====
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build  # nuxt build → generates .output/

# ===== Stage 3: Runtime =====
FROM node:20-alpine AS runner
# Slim Node.js image ~150MB (much smaller than full node:20 at 900MB)
WORKDIR /app

ENV NODE_ENV=production

# Copy only files needed at runtime (Nuxt's standalone mode)
COPY --from=builder /app/.output ./

EXPOSE 3000
CMD ["node", "server/index.mjs"]

Nuxt 3's nuxt build command uses Nitro, which by default produces standalone output (.output/ directory) containing all runtime dependencies — no additional node_modules required.

2.4 Complete GitHub Actions CI/CD Pipeline

# .github/workflows/deploy.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  # ===== Step 1: Code Quality =====
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint          # ESLint
      - run: npm run format:check  # Prettier

  # ===== Step 2: Type Check =====
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run typecheck     # vue-tsc --noEmit

  # ===== Step 3: Unit Tests =====
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit     # vitest run
        env:
          VITE_API_URL: https://api-staging.example.com
      - name: Upload coverage
        uses: codecov/codecov-action@v3

  # ===== Step 4: Build and Push Docker Image =====
  build-docker:
    name: Build & Push Docker
    runs-on: ubuntu-latest
    needs: [lint, typecheck, test]   # all three previous steps must pass
    if: github.ref == 'refs/heads/main'  # only run on main branch
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            yourusername/yourapp:latest
            yourusername/yourapp:${{ github.sha }}
          # Enable BuildKit cache for faster builds
          cache-from: type=gha
          cache-to: type=gha,mode=max
  
  # ===== Step 5: Deploy to Production =====
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    needs: [build-docker]
    environment: production  # requires protection rules configured in GitHub repo settings
    steps:
      - name: Deploy to production server
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            docker pull yourusername/yourapp:${{ github.sha }}
            docker stop app-container || true
            docker rm app-container || true
            docker run -d \
              --name app-container \
              --restart always \
              -p 3000:3000 \
              -e NODE_ENV=production \
              -e DATABASE_URL=${{ secrets.DATABASE_URL }} \
              yourusername/yourapp:${{ github.sha }}
            docker image prune -f  # clean up old images

vue-tsc type check configuration:

// tsconfig.json
{
  "compilerOptions": {
    "strict": true,
    "moduleResolution": "bundler",
    "jsx": "preserve"
  },
  "include": ["src/**/*", "env.d.ts"]
}
// package.json
{
  "scripts": {
    "typecheck": "vue-tsc --noEmit",
    "lint": "eslint . --ext .vue,.js,.ts",
    "test:unit": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

Level 3 · Design Documents and Source Code (Senior Developers)

3.1 @vue/server-renderer's renderToString Source Code

The core of @vue/server-renderer is rendering a Vue application to an HTML string:

// packages/server-renderer/src/renderToString.ts (simplified)
export async function renderToString(
  input: App | VNode,
  context: SSRContext = {}
): Promise<string> {
  if (isVNode(input)) {
    const app = createApp({ render: () => input });
    return renderToString(app, context);
  }
  
  const vnode = createVNode(input._component, input._props);
  vnode.appContext = input._context;
  
  input.provide(ssrContextKey, context);
  
  const buffer: SSRBuffer = [];
  await renderVNode(buffer, vnode, input._context, null);
  
  // Wait for all async operations to complete (teleports, etc.)
  await resolvePromises(context);
  
  return unrollBuffer(buffer as SSRBufferItem[]);
}

Key limitations of server-side rendering:

  1. No window, document, navigator: These browser globals don't exist in Node.js
  2. Lifecycle only runs through onServerPrefetch and setup(): onMounted and later hooks don't run on the server
  3. Reactive updates don't apply: Server-side rendering is one-shot and doesn't track reactive dependencies

3.2 Nuxt 3's Nitro Server Engine

Nuxt 3 uses Nitro as its underlying server engine rather than Express or Koa directly:

// Nitro routing (simplified from .nuxt/server/index.mjs)
import { createApp, fromNodeMiddleware, toNodeListener } from 'h3';

const app = createApp();

// Register API routes
app.use('/api/', apiHandler);

// Register SSR renderer
app.use('/', async (event) => {
  const ssrContext = {
    event,
    url: getRequestURL(event),
    head: createHead(),
  };
  
  const { html } = await renderNuxtApp(ssrContext);
  return html;
});

// Nitro supports multiple adapters:
// - node (run directly on Node.js)
// - cloudflare-pages (Cloudflare Workers)
// - vercel (Vercel Edge Functions)
// - netlify (Netlify Functions)
export default fromNodeMiddleware(toNodeListener(app));

3.3 The Hydration Source Code Implementation

Hydration logic in @vue/runtime-dom lives in hydration.ts:

// packages/runtime-core/src/hydration.ts (greatly simplified)

function hydrateNode(
  node: Node,          // real DOM node generated by the server
  vnode: VNode,        // client-side virtual DOM node
  parentComponent: ComponentInternalInstance | null,
  slotScopeIds: string[] | null,
  optimized: boolean
): Node | null {
  const { type, props, patchFlag, shapeFlag, children } = vnode;
  
  // Critical: associate the VNode with the real DOM node
  vnode.el = node;
  
  if (shapeFlag & ShapeFlags.ELEMENT) {
    if (node.nodeType === DOMNodeTypes.ELEMENT) {
      const el = node as Element;
      
      if (patchFlag !== PatchFlags.BAIL && !hasMismatch) {
        // No need to update content — just associate and bind events
        if (props) {
          // Only handle event listeners (don't update other DOM attributes
          // because the server already set them correctly)
          if (props.onClick) {
            patchProp(el, 'onClick', null, props.onClick, ...);
          }
        }
      } else {
        // Mismatch: force re-render (expensive!)
        patchElement(node.parentNode!, null, vnode, ...);
      }
      
      // Recursively process child nodes
      if (children) {
        hydrateChildren(el.firstChild, vnode, el, parentComponent, ...);
      }
    } else {
      // Severe mismatch: wrong type (expected div but got span)
      hasMismatch = true;
    }
  }
  
  return node.nextSibling; // return the next node to process
}

patchFlag optimization in SSR:

Vue 3's compiler annotates templates with patchFlag values indicating which content is dynamic. During hydration, Vue can skip checks on static content:

// Compiled render function (with patchFlag)
createElementVNode("div", null, [
  createElementVNode("h1", null, "Static Title"),  // static, skip hydration check
  createElementVNode("p", null, _ctx.dynamicText,  
    1 /* TEXT */),                                  // dynamic text, check during hydration
])

3.4 Production Monitoring: Health Checks and Alerting

// server/api/health.get.ts (Nuxt 3 API route)
export default defineEventHandler(async (event) => {
  const checks = {
    status: 'ok',
    timestamp: new Date().toISOString(),
    version: process.env.APP_VERSION || 'unknown',
    checks: {
      database: await checkDatabase(),
      redis: await checkRedis(),
      memory: process.memoryUsage().heapUsed < 500 * 1024 * 1024,
    }
  };
  
  const isHealthy = Object.values(checks.checks).every(Boolean);
  
  if (!isHealthy) {
    setResponseStatus(event, 503); // Service Unavailable
    checks.status = 'degraded';
  }
  
  return checks;
});

Docker health check:

HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1

Level 4 · Edge Cases and Traps (For Everyone)

Trap 1: Using localStorage/sessionStorage in SSR Causes Crashes

Broken code:

// In setup() or at module top level (executed in Node.js)
const token = localStorage.getItem('token'); // Error! localStorage doesn't exist in Node.js

Symptom: Server-side rendering throws ReferenceError: localStorage is not defined, and the page returns a 500 error.

Correct approaches:

// Option 1: useCookie (Nuxt 3 built-in, SSR-safe)
const token = useCookie('auth_token');

// Option 2: process.client check (Nuxt 3)
const token = ref(null);
if (process.client) {
  token.value = localStorage.getItem('token');
}

// Option 3: access in onMounted (universal Vue approach)
onMounted(() => {
  token.value = localStorage.getItem('token');
});

Trap 2: useFetch Response Data Loses Types After Serialization

Broken scenario:

// API returns Date objects
// server/api/events.get.ts
return { events: [{ startDate: new Date('2024-01-01') }] };

// On the client:
const { data } = await useFetch('/api/events');
// data.value.events[0].startDate is a STRING, not a Date object!
// JSON serialization turns Date → string; the client receives a string

Root cause: Nuxt's payload is transferred via JSON serialization. Date objects become strings. Map, Set, and undefined are similarly lost in translation.

Correct approach: Always return strings from the API layer, convert on the client as needed:

// API layer: always return strings
return { 
  events: events.map(e => ({
    ...e,
    startDate: e.startDate.toISOString() // string
  }))
};

// Client: convert as needed
const startDate = computed(() => 
  data.value ? new Date(data.value.events[0].startDate) : null
);

Trap 3: Exposing Sensitive Environment Variables to the Client in CI/CD

Broken configuration:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Both of these get serialized into the client HTML!
    DATABASE_URL: process.env.DATABASE_URL,       // Dangerous! DB connection string exposed
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, // Dangerous! Secret key exposed
  }
});

Correct configuration:

export default defineNuxtConfig({
  runtimeConfig: {
    // Server-only (not exposed to client)
    databaseUrl: process.env.DATABASE_URL,
    stripeSecretKey: process.env.STRIPE_SECRET_KEY,
    
    // Only 'public' properties are exposed to the client
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE,  // public API URL
      stripePublicKey: process.env.STRIPE_PUBLIC_KEY, // public key
    }
  }
});

Accessing in components:

const config = useRuntimeConfig();

// Server-only: config.databaseUrl
// Both client and server: config.public.apiBase

Trap 4: Poor Docker Layer Ordering Causes Slow Builds

Broken Dockerfile:

FROM node:20-alpine
WORKDIR /app

# Wrong: copying all files first — any source change invalidates the npm install layer
COPY . .               # any code change → this and all subsequent layers re-execute
RUN npm ci             # slow! reinstalls all dependencies every time
RUN npm run build

Correct approach: leverage Docker layer caching:

FROM node:20-alpine AS builder
WORKDIR /app

# Step 1: copy only package files (change infrequently)
COPY package.json package-lock.json ./

# Step 2: install dependencies (cached if package.json didn't change)
RUN npm ci --frozen-lockfile

# Step 3: copy source code (changes frequently — put it last)
COPY . .

# Step 4: build
RUN npm run build

Why order matters: Docker executes top-to-bottom, and each layer's cache depends on all layers above it. package.json changes far less often than source code. Putting it first allows npm ci to hit the cache, avoiding reinstalling hundreds of packages every build (saves 2–5 minutes of build time).

Trap 5: Global Maps in Nuxt Server Middleware Cause Memory Leaks

Broken scenario:

// server/middleware/cache.ts
// Wrong! This Map is a module-level global variable
const cache = new Map();

export default defineEventHandler((event) => {
  const key = getRequestURL(event).pathname;
  
  if (!cache.has(key)) {
    cache.set(key, fetchData(key)); // caches every route
  }
  
  // cache grows forever and is never cleared → memory leak
});

Correct approach: Use an LRU cache or set a TTL:

// server/middleware/cache.ts
import { LRUCache } from 'lru-cache';

// LRU cache: max 1000 entries, each expires after 5 minutes
const cache = new LRUCache({
  max: 1000,
  ttl: 1000 * 60 * 5,  // 5-minute TTL
});

export default defineEventHandler((event) => {
  const key = getRequestURL(event).pathname;
  
  if (!cache.has(key)) {
    cache.set(key, computeValue(key));
  }
  
  return cache.get(key);
});

Chapter Summary

  1. Hydration is association, not re-rendering: Vue pairs each VNode with its corresponding server-generated DOM node and binds event listeners — no new DOM is created. Hydration mismatches trigger a forced full re-render, which is more expensive than CSR. The primary rule: never use values that differ between server and client (Date.now(), Math.random(), browser APIs).

  2. Nuxt 3's routeRules enables page-level rendering decisions: Use prerender: true for the home page (build-time HTML), swr: 60 for product listings (server-cached for 60 seconds), ssr: false for the user dashboard (client-rendered private content). Choose based on page characteristics, not a one-size-fits-all approach.

  3. useFetch data travels through the HTML: Server-side useFetch data is serialized into __NUXT_DATA__ and used directly by the client without a duplicate request. However, Date, Map, and Set lose their types after serialization — the API layer should consistently return strings.

  4. The two extremes of cache strategy: index.html must never be cached (no-store); hashed assets should be cached permanently (max-age=31536000, immutable). The middle layer (API responses) uses stale-while-revalidate to balance freshness and performance.

  5. The CI/CD pipeline order is the moat protecting code quality: lint → typecheck (vue-tsc) → test (vitest) → build → docker push → deploy. Failure at any step stops subsequent steps. Multi-stage Docker builds compress images from 800MB to 25MB; proper layer ordering caches dependency installation and saves 2–5 minutes of build time per run.

Rate this chapter
4.8  / 5  (3 ratings)

💬 Comments