Skip to content
advanced Phase 15 · Production Builds and Capstone Project

Capstone: A Production SPA

Build and ship a complete app with routing, store, API layer, tests, and deployment.

1h 30m
0 problems
Topic Progress 0%

Place routes, stores, and the API layer before writing features

The capstone is where the roadmap stops being fifteen separate topics and becomes one application. The first decision is where each piece you have learned lives in the codebase — because a production SPA falls apart not from a missing feature but from a structure nobody can navigate six months later.

A layout that scales from a weekend build to a team:

src/
├── api/
│   ├── client.ts            # fetch wrapper: baseURL, auth header, error normalization
│   ├── orders.ts            # typed endpoint functions, one per resource
│   └── types.ts             # shared response contracts
├── components/              # presentational, props in / events out
├── composables/             # useCart, usePolling, useMediaQuery
├── layouts/                 # AppLayout, AuthLayout
├── router/
│   ├── index.ts             # route table, lazy imports
│   └── guards.ts            # auth redirect logic
├── stores/                  # Pinia slices: auth, cart, ui
└── views/                   # routed pages, one per route

Three rules make this structure pay for itself. Views compose, components stay dumb — a view reads stores and calls the API layer; a component receives props and emits events, so both test in isolation with the mounting patterns from Mounting Components in Tests. All HTTP goes through src/api/ — no component calls fetch directly, which puts exactly one file in mocking scope for every test:

// src/api/client.ts
const BASE = import.meta.env.VITE_API_URL

export async function api<T>(path: string, init?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      'content-type': 'application/json',
      ...(auth.token ? { authorization: `Bearer ${auth.token}` } : {}),
      ...init?.headers,
    },
  })
  if (!res.ok) throw new ApiError(res.status, await res.text())
  return res.json() as Promise<T>
}

Routes are lazy by default — every entry in the router uses () => import('@/views/CheckoutView.vue') so the initial chunk stays small, the discipline from Async Components and Code Splitting applied as a house rule rather than a per-feature decision:

// src/router/index.ts
const routes = [
  { path: '/', component: () => import('@/views/HomeView.vue') },
  { path: '/login', component: () => import('@/views/LoginView.vue'), meta: { public: true } },
  { path: '/orders/:id', component: () => import('@/views/OrderView.vue'), meta: { auth: true } },
]

Metadata on the route records (public, auth, guestOnly) is what the guard in the next chapter reads — putting the claim on the route itself keeps guard code declarative. Layouts wrap routed views through <RouterView> slots so header, navigation, and error boundary are declared once instead of copied into every view.

How the roadmap's topics land in this tree is worth seeing as one table — it is the answer to "what was all that for?":

Roadmap topic Where it lives in the capstone
Component templates, props, events components/ — the presentational contract
Composables composables/ — logic with no DOM of its own
Pinia stores stores/ — session, cart, cross-view state
Router + guards router/ — lazy routes, redirect logic
Fetch and error states api/ — one client, typed functions
Vitest + Test Utils tests/ mirroring src/ structure
Env variables import.meta.env.VITE_* read in api/client.ts
Bundle analysis, deployment CI pipeline in the final chapter

Settings come from environment variables — VITE_API_URL at build time — never hard-coded, and never secrets: anything prefixed VITE_ is public in the bundle, a constraint covered in Environment Variables in Vite.

Before writing features, pin the skeleton down: route table, one store, the API client, one view per column of that table, and a smoke test per view. The structure reviewed once now is what keeps every feature PR after it small.

Guard routes and refresh tokens with one shared refresh, then prove it with tests

Two flows decide whether the capstone feels like a real product: an unauthenticated visitor must be bounced to login and returned to where they were, and an expired token must refresh once — not five times when five requests fail together. Both are worth building deliberately, then proving with tests.

The guard lives in router/guards.ts and runs before every navigation:

router.beforeEach((to) => {
  const auth = useAuthStore()

  if (to.meta.public) return true
  if (!auth.token) {
    return { path: '/login', query: { redirect: to.fullPath } }   // remember the destination
  }
  if (to.meta.guestOnly && auth.token) return { path: '/' }
  return true
})

Two details make this production-grade: redirect: to.fullPath preserves query and hash so the user lands back on /orders/42?tab=invoice, and meta flags on the route records keep guard logic declarative instead of a growing path blacklist. The store reads token state from Pinia, which the guard creates through useAuthStore() outside the component tree — Pinia supports this once setActivePinia is configured, and it is the same outside-a-component pattern composables rely on.

Token refresh is where most implementations go wrong. Five parallel requests hit 401 at the same moment; a naive interceptor refreshes on each, and the refresh endpoint rotates the token — four of those calls invalidate the whole session. The fix is a single shared refresh promise: the first 401 starts the refresh, everyone else awaits that same promise.

let refreshing: Promise<string> | null = null

export async function request(path: string, init?: RequestInit) {
  const first = await api(path, init)
  if (first.status !== 401) return first.json()

  refreshing ??= refreshAccessToken()          // only the first caller refreshes
  try {
    const token = await refreshing
    return (await retryWith(token)).json()     // others join this line
  } finally {
    refreshing = null                          // ready for the next real expiry
  }
}

??= is the whole trick: concurrent failures collapse into one refresh, each caller retries with the new token, and the flag clears in finally whether the refresh succeeded or not.

Now the tests, which is where this topic reunites with the testing phase. The suite mocks the API module at its boundary and mounts the app with the real router:

vi.mock('@/api/client', () => ({ api: vi.fn() }))
import { api } from '@/api/client'

test('redirects an unauthenticated visitor and returns them after login', async () => {
  const router = createRouter({ /* real route table */ })
  const wrapper = mount(RouterViewHost, { global: { plugins: [router, pinia] } })

  await router.push('/orders/42')
  await flushPromises()

  expect(wrapper.find('h1').text()).toBe('Sign in')
  expect(router.currentRoute.value.query.redirect).toBe('/orders/42')
})

test('two parallel 401s trigger exactly one refresh', async () => {
  const refresh = vi.mocked(refreshAccessToken)
  await Promise.all([request('/a'), request('/b')])
  expect(refresh).toHaveBeenCalledTimes(1)
})

The critical-flows list the capstone must cover before deploy: login success and failure, the guarded redirect round-trip, add-to-cart from a product page, checkout validation blocking an empty address, logout clearing the store. Stub the heavy children, mock @/api/client, use fake timers for any debounce — the isolation toolkit from Mocking Modules and Stubbing Children applies unchanged. What the tests must not do is mock the router or the store: those are the wiring under test, and Testing Composables in Isolation drew that same line — mock the boundary, run the code you are claiming about.

Add the failure branch of refresh while you are here, because it is where sessions actually die: if the refresh request itself returns 401, clear the token, drop the queued retries, and navigate to login — a user stuck on a page that silently 401s forever is worse than one who is asked to sign in again. The test asserts exactly that: request('/a') rejects after a failed refresh, auth.token is null, and the store's logout spy ran once. Three tests — redirect round-trip, single-flight refresh, failed-refresh logout — now pin the entire auth behavior, and any refactor of guards.ts or the interceptor that breaks a session has to go green against them first.

With auth proven green, the last chapter measures what it all costs to ship.

Set the budget, enforce it in CI, and ship through the pipeline

A capstone that works on your laptop but ships a 900 KB bundle or fails Lighthouse under 70 is not finished. The finishing line is measurable: a budget written down, checked in CI, failing the build when it is exceeded. Numbers first, opinions later.

Run the real build and read the output:

npm run build          # vite build — note the largest chunks
npx vite-bundle-visualizer   # which imports dominate the graph
npm run preview        # production server locally; verify routing rewrites

A realistic first measurement for a routed SPA: index chunk around 70 KB gzip (Vue + router), a 90 KB vendor chunk, and feature chunks of 15–40 KB each. If checkout alone ships 200 KB, something heavy — a date library, a chart kit — entered through a barrel import, and the fix is the chunk strategy you already know: split vendor groups with manualChunks and lazy-load the route that needs them.

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router', 'pinia'],   // changes rarely → long cache
        },
      },
    },
  },
})

Grouping the framework separately means a product release re-uploads only your code's chunks while visitors keep the cached vendor file — the cache-hit half of the deployment story.

Write the budget as a table, and put the same table in CI:

Metric Budget Measured how
Initial JS (gzip) ≤ 170 KB bundle output / Lighthouse
Largest route chunk ≤ 50 KB vite-bundle-visualizer
LCP ≤ 2.5 s Lighthouse, throttled mobile
INP ≤ 200 ms Lighthouse / CrUX
CLS ≤ 0.1 Lighthouse
Lighthouse performance ≥ 90 lhci autorun with assert

Lighthouse CI enforces this mechanically rather than by memory:

{
  "ci": {
    "assert": {
      "assertions": {
        "categories:performance": ["error", { "minScore": 0.9 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "total-byte-weight": ["error", { "maxNumericValue": 512000 }]
      }
    }
  }
}

The pipeline order matters — fail fast on the cheap checks: lint → unit tests (vitest run) → build → bundle budget → Lighthouse against the preview server → deploy. A green unit suite is the gate; Lighthouse runs on the built output because source-level performance tells you nothing about what users download.

Deployment closes the loop with routing, and it is where the SPA rewrite rule earns its keep: unknown paths fall back to index.html so the history router can resolve /orders/42, real files and /api/* are excluded from the rewrite, hashed assets get cache-control: immutable, and index.html gets no-cache. Every point of that checklist is detailed in Deploying a Single-Page App — the capstone's job is to prove your host's config actually implements it, which you verify with a direct request to a deep link:

curl -I https://app.example.com/orders/42   # must return 200 with the index document
curl -I https://app.example.com/assets/index-a1b2c3.js   # must return immutable caching

Then walk the release like a user on a throttled connection: cold load, deep-link refresh, login redirect round-trip, offline error state. Anything that only worked on npm run dev surfaces here — and the four checks that catch regressions later are the same four the capstone has used all along: route rewrites respond, the bundle stays inside its budget, the guarded flows pass their tests, and Lighthouse holds its score. Ship it, then keep that pipeline running on every commit — the capstone is not a demo you present once; it is the project whose tests, budgets, and deploy checks you can defend line by line in the interview that follows.

Quiz

1. Why route every HTTP call through src/api/client.ts instead of calling fetch in components?

Question 1 options

2. Five in-flight requests each receive a 401 while the refresh token is valid. What does the correct client do?

Question 2 options

3. A guard sends an unauthenticated visitor away from /orders/42?tab=invoice. What must it preserve?

Question 3 options

4. How are performance budgets actually enforced rather than remembered?

Question 4 options

Flashcards

Question

What structure does the capstone impose on src/, and why the api/ layer?

Answer

api/ (client + typed functions), components/ (props in, events out), composables/, layouts/, router/ (lazy imports + guards), stores/ (Pinia slices), views/ (routed pages). The api/ layer concentrates auth headers, error normalization, and mocking into one boundary.

Question

How does the auth guard preserve the destination?

Answer

Return { path: '/login', query: { redirect: to.fullPath } } from beforeEach when unauthenticated (and read meta flags for public/guest-only routes), then navigate to query.redirect after successful login — fullPath keeps query and hash.

Question

What is single-flight token refresh and why is it required?

Answer

The first 401 starts the refresh; concurrent 401s await the same promise (refreshing ??= refresh()) and each retries once with the new token. Required because refresh endpoints rotate tokens — parallel refreshes would invalidate the session.

Question

What must capstone tests mock, and what must they keep real?

Answer

Mock the API module boundary (vi.mock('@/api/client')) and stub heavy children or timers; keep the real router, route meta, and Pinia store — the wiring those tests claim about must execute, or the test proves only its own mocks.

Question

Name the capstone's release gates.

Answer

Lint → vitest run → build → bundle budget → Lighthouse assertions (LCP ≤ 2.5 s, CLS ≤ 0.1, INP ≤ 200 ms, initial JS ≤ 170 KB gzip) → deploy with rewrite/cache rules → curl deep links to verify the fallback, then walk the release as a user.

Revision Notes

Key Takeaways

  • 1. Structure first: one API layer, lazy routes, Pinia slices, presentational components — the roadmap's topics map to concrete directories, and each rule keeps tests and reviews small
  • 2. Guards preserve intent with query.redirect = to.fullPath; refresh is single-flight because refresh endpoints rotate tokens
  • 3. Tests mock the api/ boundary but keep router and store real — mock the edge, run the wiring you claim about
  • 4. Performance is a written budget (initial JS, LCP, INP, CLS) enforced by CI assertions, not a number someone remembers
  • 5. Ship order is lint → test → build → budget → Lighthouse → deploy, then verify rewrites, cache headers, and deep-link refresh like a real user

Interview Tips

  • • "Walk me through your project structure" — map each directory to a responsibility (api boundary, presentational components, store slices, lazy routes) and say what each rule buys in tests or reviews
  • • "How do you handle expired tokens?" — single-flight refresh: shared promise, parallel 401s join, each retries once; mention refresh failure clears the session instead of looping
  • • "How do you protect a route?" — beforeEach with meta flags and redirect = to.fullPath, then restore the destination after login; note guest-only inversion for logged-in users
  • • "How do you keep a bundle small?" — lazy routes, vendor chunking, written budgets failing CI, and Lighthouse assertions on the built output — emphasize that measurement plus enforcement beats intent

Cheat Sheet

Capstone: A Production SPA

Structure:

src/
├── api/        client.ts (baseURL, auth header, typed errors) + one file per resource
├── components/ presentational: props in, events out
├── composables/ useCart, usePolling
├── layouts/    AppLayout, AuthLayout
├── router/     lazy route table + guards.ts
├── stores/     Pinia: auth, cart, ui
└── views/      one per route, wired via () => import(...)

Config: import.meta.env.VITE_API_URL only — the bundle is public, no secrets.

Auth:

router.beforeEach((to) => {
  if (to.meta.public) return true
  if (!auth.token) return { path: '/login', query: { redirect: to.fullPath } }
  if (to.meta.guestOnly && auth.token) return { path: '/' }
  return true
})

let refreshing = null
refreshing ??= refreshAccessToken()   // one refresh, all 401s join

Tests: vi.mock('@/api/client') + real router/Pinia; critical flows = login, redirect round-trip, single-flight refresh, failed-refresh logout, cart add, checkout validation.

Release gates (in order): lint → vitest run → vite build → bundle budget → Lighthouse asserts → deploy.

Metric Budget
Initial JS (gzip) ≤ 170 KB
Largest route chunk ≤ 50 KB
LCP / INP / CLS ≤ 2.5 s / ≤ 200 ms / ≤ 0.1
Lighthouse performance ≥ 90
Deploy: rewrite unknown paths to index.html (excluding assets and /api), immutable on hashed assets, no-cache on index.html; verify with curl -I on a deep link.

Continue Learning

Before You Start

Next Topics

Related Reading