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:
- The true nature of hydration (association, not re-rendering), the 5 common root causes of hydration mismatches, and how to prevent them
- The execution environment differences between
useFetchand$fetch, and how to configure Nuxt 3'srouteRulesfor hybrid rendering - Multi-stage Docker builds (800MB → 25MB) and a complete GitHub Actions CI/CD pipeline configuration
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:
- Within 60 seconds: return from cache directly (fastest)
- 60s to 3600s: return the stale cache value while refreshing in the background (non-blocking)
- After 3600s: must re-fetch
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:
- No
window,document,navigator: These browser globals don't exist in Node.js - Lifecycle only runs through
onServerPrefetchandsetup():onMountedand later hooks don't run on the server - 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
-
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). -
Nuxt 3's routeRules enables page-level rendering decisions: Use
prerender: truefor the home page (build-time HTML),swr: 60for product listings (server-cached for 60 seconds),ssr: falsefor the user dashboard (client-rendered private content). Choose based on page characteristics, not a one-size-fits-all approach. -
useFetch data travels through the HTML: Server-side
useFetchdata is serialized into__NUXT_DATA__and used directly by the client without a duplicate request. However,Date,Map, andSetlose their types after serialization — the API layer should consistently return strings. -
The two extremes of cache strategy:
index.htmlmust never be cached (no-store); hashed assets should be cached permanently (max-age=31536000, immutable). The middle layer (API responses) usesstale-while-revalidateto balance freshness and performance. -
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.