# Errors, Security and Deployment

Course: [Vue.js](https://www.devtutorials.ai/learn/vue) · Section: Production Vue
Source: https://www.devtutorials.ai/learn/vue/production/errors-security-and-deployment

> onErrorCaptured and error boundaries, error propagation, app.config.errorHandler and warnHandler, friendly states for network, HTTP and validation errors, untrusted templates and v-html, validating external data, public env variables and secrets, folder structure and naming, .env files, modes and typed env, and building and deploying with a base path and SPA fallback, ESLint and Prettier, and a CI pipeline.

## Shipping a Real App

- A finished app must survive bad networks, bad data and bad luck.
- It must not let strangers run code on your page or read your secrets.
- And it must be organised so a team can work on it, and built so a host can serve it.
- This lesson covers all three: errors, security and deployment.

**Simple lesson:** a production app handles errors kindly, stays safe, and is easy to build and host.

### A Real-World Example

- Meera's menu page has a "Today's offers" widget that sometimes crashes.
- Without care, one broken widget can blank the whole page.
- An **error boundary** catches the crash and shows a small message instead.

const error = ref<string | null>(null);

onErrorCaptured((err) => {
    error.value = (err as Error).message;
    console.log("Caught by the boundary:", error.value);
    return false;
});
</script>

<template>
    <div v-if="error" class="fallback">Sorry, this part could not load. <button @click="error = null">Try again</button></div>
    <slot v-else />
</template>
`,
    "OffersWidget.vue": `<script setup lang="ts">

const props = defineProps({ offers: Array });
// Bug: offers is missing, so .length throws while the widget renders.
const count = computed<number>(() => (props.offers as string[]).length);
</script>

<template>
    <p>{{ count }} offers today</p>
</template>
`,
    "App.vue": `<script setup lang="ts">
</script>

<template>
    <h2>Meera's Kitchen menu</h2>
    <p>Dosa · Idli · Vada (the rest of the page still works)</p>
</template>

<style>
.fallback { background: #fff4d6; padding: 6px 10px; border-radius: 8px; }
</style>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

- **What happened:** `OffersWidget` crashed, because `offers` was missing.
- The boundary caught it with `onErrorCaptured`, showed a message, and the rest of the page stayed.

### What You Will Learn

1. How `onErrorCaptured` and error boundaries work.
2. How errors travel up the component tree.
3. A global handler with `app.config.errorHandler`.
4. Friendly states for network, HTTP and validation errors.
5. How to check data from outside the app.
6. XSS, `v-html` and untrusted templates.
7. Why `VITE_` variables are public, and where secrets belong.
8. Folder structure, linting, formatting, `.env` files and modes.
9. CI, building, the base path, SPA fallback and hosting.

## Catching Errors

- `onErrorCaptured((err, instance, info) => ...)` runs when a **child** throws during render, a hook, a watcher or an event handler.
- Return `false` to stop the error from going further up.
- Otherwise it keeps travelling up to the next boundary, then to `app.config.errorHandler`.
- `info` says where it happened, such as `"render function"` or `"setup function"`.

<figure className="vue-fig ngc-fig">
<svg viewBox="0 0 640 200" role="img" aria-labelledby="fig-vue-errors" style={{ width: "100%", maxWidth: 640, height: "auto" }}>
  <title id="fig-vue-errors">Animation: an error thrown in OffersWidget travels up to ErrorBoundary, which returns false and stops it. Without that, it would continue to App and then to app.config.errorHandler</title>
  <g style={{ fontFamily: "var(--mono)", fontSize: 12, fontWeight: 700 }}>
    <rect x="230" y="150" width="180" height="36" rx="9" style={{ fill: "var(--ngc-red-soft)", stroke: "var(--ngc-red)" }} /><text x="320" y="173" textAnchor="middle" style={{ fill: "var(--ngc-red)" }}>OffersWidget throws</text>
    <rect x="230" y="100" width="180" height="36" rx="9" style={{ fill: "var(--ngc-green-soft)", stroke: "var(--ngc-green)" }} /><text x="320" y="123" textAnchor="middle" style={{ fill: "var(--ngc-green)" }}>ErrorBoundary</text>
    <rect x="230" y="50" width="180" height="36" rx="9" style={{ fill: "var(--ngc-win)", stroke: "var(--ngc-slate)" }} /><text x="320" y="73" textAnchor="middle" style={{ fill: "var(--ngc-slate)" }}>App</text>
    <rect x="230" y="5" width="180" height="32" rx="9" style={{ fill: "var(--ngc-purple-soft)", stroke: "var(--ngc-purple)" }} /><text x="320" y="26" textAnchor="middle" style={{ fill: "var(--ngc-purple)" }}>app errorHandler</text>
  </g>
  <circle r="8" cx="440" style={{ fill: "var(--ngc-red)" }}><animate attributeName="cy" values="168;118;118" keyTimes="0;0.4;1" dur="5s" repeatCount="indefinite" /><animate attributeName="opacity" values="1;1;0;0" keyTimes="0;0.45;0.5;1" dur="5s" repeatCount="indefinite" /></circle>
  <text x="470" y="123" opacity="0" style={{ fill: "var(--ngc-green)", fontFamily: "var(--sans)", fontSize: 13, fontWeight: 700 }}><animate attributeName="opacity" values="0;0;1;1;0" keyTimes="0;0.42;0.45;0.95;1" dur="5s" repeatCount="indefinite" />return false: stop here</text>
  <text x="40" y="73" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--sans)", fontSize: 12 }}>without return false,</text>
  <text x="40" y="91" style={{ fill: "var(--ngc-slate)", fontFamily: "var(--sans)", fontSize: 12 }}>it keeps going up ↑</text>
</svg>
<figcaption>Errors travel up through boundaries until one stops them, then reach the global handler.</figcaption>
</figure>

### The Global Handler

- `app.config.errorHandler = (err, instance, info) => ...` catches every error no boundary stopped.
- Send errors to a reporting service from here, such as Sentry.
- `app.config.warnHandler` catches Vue warnings in development.
- Errors outside Vue, such as in a `setTimeout` or an un-awaited Promise, never reach it. Catch those with `window.addEventListener("unhandledrejection", ...)` and `"error"`.

function placeOrder(): void {
    throw new Error("Kitchen printer is offline");
}
</script>

<template>
    <button @click="placeOrder">Place order</button>
    <p v-if="lastError">⚠ {{ lastError }}</p>
</template>
`,
  }}
/>

`onErrorCaptured` and `errorHandler` do not catch errors in your own `setTimeout` callbacks or in Promises you never await. Use `try`/`catch` in async code, and listen for `unhandledrejection` on `window` for the rest.

**Simple lesson:** boundaries catch errors near where they happen. `errorHandler` catches the rest.

## Friendly Error States

- **Network error:** no connection. Say "You seem to be offline" and offer to retry.
- **HTTP error:** the server said 404 or 500. Explain in plain words, without codes for normal users.
- **Validation error:** the user's input was wrong. Say which field and how to fix it, as in **Events and Forms**.
- Never show a blank page or a raw stack trace.

type State = "idle" | "loading" | "ok" | "offline" | "server";
const state = ref<State>("idle");
const attempts = ref<number>(0);

async function load(): Promise<void> {
    state.value = "loading";
    attempts.value++;
    try {
      const url = attempts.value < 2 ? "https://api.meeraskitchen.dev/error" : "https://api.meeraskitchen.dev/dishes";
      const res = await fetch(url);
      state.value = res.ok ? "ok" : "server";
    } catch {
      state.value = "offline";
    }
}
</script>

<template>
    <button v-if="state === 'idle'" @click="load">Load menu</button>
    <p v-else-if="state === 'loading'">Loading…</p>
    <p v-else-if="state === 'server'">Our kitchen server is having trouble. <button @click="load">Try again</button></p>
    <p v-else-if="state === 'offline'">You seem to be offline. <button @click="load">Retry</button></p>
    <p v-else>✓ Menu loaded on attempt {{ attempts }}</p>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

## Security

### XSS and Untrusted Content

- **XSS** = a stranger's text runs as code on your page. You met it in **Templates and Directives**.
- `{{ }}` always escapes text. It is safe for any user input.
- `v-html` is not. Use it only for HTML you trust, or after cleaning it with a sanitiser such as DOMPurify.
- Never compile a template that a user wrote. Templates can run any JavaScript.
- Never put user input into `:href` without checking it. `javascript:` links run code.

const review = ref<string>('<img src=x onerror="console.log(\\'XSS ran!\\')"> Great dosa');
const website = ref<string>("javascript:console.log('link code ran')");

const safeLink = computed(() => (/^https?:\\/\\//.test(website.value) ? website.value : "#"));

function textOnly(html: string): string {
    return new DOMParser().parseFromString(html, "text/html").body.textContent ?? "";
}
</script>

<template>
    <p>Escaped by {{ "{" }}{{ "{" }} }}: {{ review }}</p>
    <p>Cleaned to text: {{ textOnly(review) }}</p>
    <p><a :href="safeLink">Visitor's website (checked)</a></p>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

- **What happened:** the review was shown as text. No code ran.
- The `javascript:` link was replaced with `#`, because only `http` and `https` were allowed.

### Validating Data from Outside

- Data from a server, `localStorage` or the URL may not have the shape you expect.
- TypeScript types are removed at run time. They cannot stop bad data.
- Check the shape yourself before using it, with a **type guard**, or a library such as Zod.

interface Dish {
    id: number;
    name: string;
    price: number;
}

function isDish(x: unknown): x is Dish {
    const d = x as Partial<Dish>;
    return typeof d?.id === "number" && typeof d.name === "string" && typeof d.price === "number" && d.price >= 0;
}

const fromServer: unknown[] = JSON.parse(
    '[{"id":1,"name":"Dosa","price":90},{"id":2,"name":"Idli"},{"id":3,"name":"Vada","price":-5},{"id":4,"name":"Pongal","price":70}]',
);

const dishes = fromServer.filter(isDish);
const skipped = fromServer.length - dishes.length;
</script>

<template>
    <ul><li v-for="d in dishes" :key="d.id">{{ d.name }} ₹{{ d.price }}</li></ul>
    <p>{{ skipped }} bad item(s) skipped</p>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

### Secrets and Env Variables

- Variables in `.env` that start with `VITE_` are **built into the JavaScript** the browser downloads.
- Anyone can read them in DevTools. They are **public**.
- Put only public settings there: the API address, a public map key with domain limits.
- Real secrets (database passwords, payment keys) stay on the **server**. The browser asks the server, and the server uses the secret.

Never put a secret in a `VITE_` variable, or anywhere in your front-end code. "Hidden" in a minified file still means public.

### Content Security Policy

- A **Content Security Policy (CSP)** = a rule list the server sends, telling the browser which scripts and styles may run.
- It blocks most XSS even if a bug lets bad HTML in.
- A Vite build works with a strict CSP, because templates are compiled ahead of time. The full build with `compile` would need `unsafe-eval`, another reason not to compile templates in the browser.

```bash
# An example response header from your host
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://api.meeraskitchen.dev
```

**Simple lesson:** `{{ }}` for user text, check outside data, and keep secrets on the server.

## Project Structure

<div className="tscroll">

| Folder | Holds | Example |
|---|---|---|
| `src/views/` | Page components used by the router | `MenuPage.vue` |
| `src/components/` | Reusable parts | `DishCard.vue`, `BaseButton.vue` |
| `src/composables/` | `use` functions | `useFetch.ts` |
| `src/stores/` | Pinia stores | `cart.ts` |
| `src/services/` or `src/api/` | API calls | `dishes.ts` |
| `src/router/` | Routes and guards | `index.ts` |
| `src/utils/` | Plain helper functions | `money.ts` |
| `src/assets/` | Images and CSS processed by Vite | `logo.svg` |

</div>

- Components: PascalCase, two or more words. Composables: `useX`. Stores: `useXStore`.
- Keep one component per file, named like the file.

## Linting and Formatting

- **ESLint** finds mistakes: unused variables, missing `:key`, a prop changed by a child.
- **Prettier** formats code the same way for everyone, so reviews talk about logic, not spaces.
- `create-vue` sets both up when you tick them. The ESLint config looks like this:

```ts
// eslint.config.ts
import pluginVue from "eslint-plugin-vue";
import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript";
import skipFormatting from "@vue/eslint-config-prettier/skip-formatting";

export default defineConfigWithVueTs(
  { files: ["**/*.{ts,mts,tsx,vue}"] },
  { ignores: ["**/dist/**", "**/coverage/**"] },
  pluginVue.configs["flat/recommended"],
  vueTsConfigs.recommended,
  skipFormatting, // Prettier formats; ESLint does not fight it
);
```

```bash
npm run lint      # eslint . --fix
npm run format    # prettier --write src/
```

- `flat/essential` catches errors only. `flat/recommended` also checks style and naming rules.
- In VS Code, install the **ESLint**, **Prettier** and **Vue (Official)** extensions and turn on "format on save".

## Checking Every Change (CI)

- **CI (continuous integration)** = a server runs your checks on every push, before anything is deployed.
- A typical Vue pipeline: install → lint → type-check → test → build.
- If any step fails, the change does not ship.

```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run lint
      - run: npx vue-tsc --noEmit
      - run: npx vitest run
      - run: npm run build
```

- Most hosts (Netlify, Vercel, Cloudflare Pages) also deploy a **preview URL** for each pull request.

**Simple lesson:** lint and format on save, and let CI run lint, types, tests and build before every deploy.

## .env Files and Modes

- `.env` = loaded in every mode. `.env.development` and `.env.production` add mode-specific values.
- `.env.local` = your own machine only. Never commit it.
- `import.meta.env` gives `MODE`, `DEV`, `PROD`, `BASE_URL` and every `VITE_` variable.
- `npm run dev` uses the `development` mode. `npm run build` uses `production`.

const env = import.meta.env;
</script>

<template>
    <p>Mode: {{ env.MODE }} (dev: {{ env.DEV }}, prod: {{ env.PROD }})</p>
    <p>Shop: {{ env.VITE_SHOP_NAME }}</p>
    <p>API: {{ env.VITE_API_URL }}</p>
    <p>DB_PASSWORD in the browser: {{ env.DB_PASSWORD ?? "undefined (good: no VITE_ prefix)" }}</p>
</template>
`,
    "main.ts": `import { createApp } from "vue";

createApp(App).mount("#app");
`,
  }}
/>

- In TypeScript, describe your variables in `env.d.ts`, so typos are caught:

```ts
// env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_SHOP_NAME: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
```

## Building and Deploying

- `npm run build` writes optimised files to `dist/`: `index.html`, JavaScript and CSS with hashed names.
- Hashed names (`index-3f9a1c.js`) let hosts cache files forever. A new build gets new names.
- `npm run preview` serves `dist/` locally to check the build.
- Any static host works: Netlify, Vercel, Cloudflare Pages, GitHub Pages, or a CDN bucket.

### Base Path

- If the app lives in a sub-folder, such as `https://example.com/kitchen/`, set `base` in `vite.config.ts`.
- Vue Router uses it too: `createWebHistory(import.meta.env.BASE_URL)`.

```ts
// vite.config.ts
export default defineConfig({
  plugins: [vue()],
  base: "/kitchen/",
});
```

### SPA Fallback

- With web history, a visitor may open `/menu` directly, or refresh on it.
- The host has no `menu` file, so it would answer 404.
- Tell the host to answer every unknown path with `index.html`. Vue Router then shows the right page.

```bash
# Netlify: public/_redirects
/*    /index.html   200

# nginx
location / {
  try_files $uri $uri/ /index.html;
}
```

Before going live: run the tests, build, preview, check Lighthouse, open a deep link like `/menu/3` directly, refresh it, and test on a phone.

**Simple lesson:** build to `dist/`, set `base` for sub-folders, and send every path to `index.html`.

## Installable Apps (PWA)

- A **Progressive Web App (PWA)** can be installed on a phone's home screen and can work offline.
- It needs a **web app manifest** (name, icons, colours) and a **service worker** that caches files.
- With Vite, the `vite-plugin-pwa` plugin creates both for you.

```ts
// vite.config.ts in a real project (npm install -D vite-plugin-pwa)
import { VitePWA } from "vite-plugin-pwa";

export default defineConfig({
  plugins: [
    vue(),
    VitePWA({
      registerType: "autoUpdate",
      manifest: { name: "Meera's Kitchen", short_name: "Kitchen", theme_color: "#2f9e6e" },
    }),
  ],
});
```

- Test offline behaviour with DevTools → Application → Service workers. The lab cannot install a service worker.

## TL;DR

- **`onErrorCaptured`** = catches errors from child components. `return false` stops them.
- **Error boundary** = a component that shows a fallback when its children fail.
- **`app.config.errorHandler`** = the last stop for uncaught errors. Report them here.
- **Friendly states** = loading, offline, server error, validation error, with a retry.
- **XSS** = use `{{ }}`. `v-html` only for trusted or sanitised HTML.
- **Validate outside data** = type guards or Zod. TypeScript types vanish at run time.
- **`VITE_` variables** = public. Secrets stay on the server.
- **Structure** = views, components, composables, stores, services, router, utils.
- **Deploy** = `npm run build` → `dist/`, `base` for sub-folders, SPA fallback to `index.html`.

## Common mistakes

<div className="tscroll">

| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| No boundary around risky widgets | One error only breaks one part | Wrap them in an error boundary | The whole page goes blank |
| Raw error text for users | Developers' messages are fine | Plain, kind words and a retry | Confused or scared users |
| `v-html` with user input | It is only formatting | `{{ }}`, or sanitise first | Strangers run code on your page |
| Trusting API data shapes | TypeScript checks it | Validate at run time | Crashes on unexpected data |
| Secrets in `VITE_` variables | `.env` is private | Keep secrets on the server | Anyone can read them |
| Committing `.env.local` | It is part of the project | Add it to `.gitignore` | Private values leak into the repo |
| No SPA fallback on the host | Deep links work by themselves | Send every path to `index.html` | 404 on refresh |
| Wrong `base` for a sub-folder | Paths fix themselves | Set `base` and the router's history base | Blank page, missing files |

</div>

## Check Yourself

## Exercises

## What to Remember

- Wrap risky parts in error boundaries. Report the rest in `errorHandler`.
- Show kind, specific error states with a way to retry.
- Use `{{ }}` for user text, and validate data from outside the app.
- `VITE_` variables are public. Secrets stay on the server.
- Build to `dist/`, set `base` if needed, and add the SPA fallback.

Next: **Capstone: Build Meera's Kitchen**
