# Developing Lt Frontend

> Handles ALL Nuxt 4 and Vue frontend work: components, composables, forms (Valibot), API integration (types.gen.ts, sdk.gen.ts), authentication (Better Auth), SSR, and Playwright E2E. Supports monorepos (projects/app/, packages/app/). Activates on .vue files, nuxt.config.ts, Nuxt UI, TailwindCSS, files under app/{components,composables,pages,interfaces,layouts}/, and on "generate types", "Valibot form", "useOverlay modal", "Playwright E2E", "Frontend bauen". NOT for NestJS backend (use generating-nest-servers). NOT for framework-agnostic security theory (use general-frontend-security).

- Skill: `lennetech/developing-lt-frontend` (Agent Skill, multi-file: 18 files)
- Install (CLI): `npx skillmds@latest add lennetech/developing-lt-frontend`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lennetech/developing-lt-frontend/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: lenneTech (https://skillmd.com/u/lennetech)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/lennetech/developing-lt-frontend

---


# lenne.tech Frontend Development

## Gotchas

- **The current `nuxt-base-starter` runs vitest+oxlint+oxfmt, not eslint+prettier** — Projects originally generated from older starters still ship `eslint` + `prettier` + Playwright-only. When aligning a project with the current starter, expect to migrate the entire toolchain: install `oxlint` + `oxfmt` + `vitest` + `@vitejs/plugin-vue` + `happy-dom`, drop `eslint` + `prettier` + `jsdom`, add `tests/unit/setup.ts` + `tests/unit/mocks/nuxt-imports.ts`, and update `package.json` scripts to `test:unit`, `test:e2e`, `lint`, `format`, `format:check`, plus the `check` / `check:fix` aggregate. Full recipe lives in the `modernizing-toolchain` skill (Phase 4).
- **Nuxt's PORT vs NITRO_PORT** — Some Nitro versions read `process.env.PORT` as a string and feed it directly into `net.Server#listen`, which crashes with `ERR_SOCKET_BAD_PORT options.port should be >= 0 and < 65536. Received type string`. Always prefer `NITRO_PORT=<num>` for the production build (`node .output/server/index.mjs`) — `NITRO_PORT` is the documented Nitro-specific knob, goes through Nitro's own env loader, and is coerced to number reliably. The Nuxt dev server (`nuxt dev`) is unaffected — `nuxt.config.ts` `devServer.port` works as expected.
- **Aligning with the upstream starter is a wholesale dep sync, not a curated pick** — When the project is being brought to the current `nuxt-base-starter` baseline, sync every dep version (both `dependencies` and `devDependencies`) to what the starter ships and read the CHANGELOG of any package whose major moved. The recurring trap that a blanket version-sync does **not** fix is **missing direct deps after a peer-restructure**: when `Rollup failed to resolve import "X"` (or an equivalent module-not-found at install time) appears, the wrapper package no longer pulls "X" transitively — declare X as a direct dependency in `package.json`, even if no app code imports it directly.
- **`pnpm run generate-types` needs a RUNNING API — and on the current starter it refuses to guess which one.** The generator fetches the OpenAPI schema from the API. Since DEV-2802 the starter resolves that URL instead of defaulting: `NUXT_API_URL` from the **shell** (a `.env` file is not read), else `<repo-root>/.lt-dev/.env` (written by `lt dev up`, which also carries the `NODE_EXTRA_CA_CERTS` the Caddy HTTPS host needs), else a hard exit 1 with an actionable message. It additionally **refuses** a URL belonging to a different `lt dev` project. So under `lt dev up` the documented call needs no extra env, and a stale or foreign API can no longer be generated from silently. **Do not "fix" that hard failure by re-adding a fallback** — the old `http://localhost:3000` default is exactly the bug: on a machine with parallel worktrees that port belongs to whichever project holds it, and the generator then wrote `types.gen.ts` / `sdk.gen.ts` from a foreign contract, reported success and exited 0. **Older projects** that have not adopted the guard still carry that silent fallback; there, verify the API is up before regenerating (`curl -k https://api.<slug>.localhost/health`, or `curl http://localhost:3000/health` in classic mode) and check that an expected endpoint really appears in `sdk.gen.ts`.
- **UI language: detect it from the project — NEVER assume German.** UI text (labels, buttons, placeholders, toasts) must match the language the project already uses. Determine it in this order: **(1)** an explicit project rule wins — check the project's `CLAUDE.md`, a conventions doc, or i18n config; **(2)** otherwise infer from existing UI files — match the language already used across `*.vue` pages/components; **(3)** only default to German for a true greenfield with no rule and no existing UI text. **NEVER bulk-translate an existing app from one language to another** — silently flipping an established English UI to German (or vice-versa) is a destructive, review-failing change that has broken a whole project before. The project — not this plugin — decides the language; once detected, stay consistent with it (incl. `du` vs `Sie` tone for German).
- **Use `useOverlay()` for modals — NOT conditional rendering** — The default instinct is `<MyModal v-if="showModal" />`. This bypasses Nuxt UI's modal stack, breaks focus trapping, and causes z-index issues with nested dialogs. The correct pattern is `useOverlay().create(ModalComponent)` from composables. See `reference/modals.md`.
- **`types.gen.ts` and `sdk.gen.ts` are GENERATED — never hand-edit** — Manual changes are overwritten on next `generate-types` run. If a type is missing, the fix is on the API side (add `@ApiProperty`, `@Field`, etc.) not in the generated file. `.gitignore` does NOT ignore these files — they ARE committed, but only via the regeneration command.
- **Better Auth derives its origins from BASE_URL/APP_URL — not from a hardcoded port number.** When `lt dev up` is used, these env vars are set automatically to the project's stable HTTPS URLs (`https://api.<slug>.localhost`, `https://<slug>.localhost`) and auth works regardless of internal port. The legacy "3000/3001 only" rule applies ONLY to non-migrated projects with hardcoded URLs. Run `lt dev init` once to migrate. See `managing-dev-servers` skill for the full URL rules.
- **Nothing is fetched from a foreign host at runtime — ever.** No CDN scripts, no Google Fonts, no icon sets pulled over the wire, no web-worker files loaded from `unpkg`/`jsdelivr`. Every asset ships in the build or is served from our own host. This is not a preference: a PDF viewer that loaded its `pdfjs-dist` worker from `https://unpkg.com/...` meant (1) **no PDFs at all** whenever that CDN was slow, blocked by a corporate firewall, or down — surfacing as the useless "Setting up fake worker failed", and turning a 5-second test into an 18-minute hang; (2) a request leaving for a foreign CDN on **every single view**, which breaks the DE/DSGVO hosting promise these projects are sold on; (3) a third party able to serve different code tomorrow — a supply-chain hole in the middle of the product. Wire assets through the bundler instead: with Vite/Nuxt that is `import workerUrl from "pkg/file.mjs?url"`. When adding any dependency that renders, decodes or workers in the browser, check whether it fetches something at runtime — `grep -rnoE "https?://" app/` over the source, and check the browser's network tab against the built app for third-party hosts.
- **Limit local Playwright runs to new + affected specs to keep TDD loops fast** — The full Playwright suite is slow and runs in **CI**. During local development / TDD, default to running only the new + affected specs via `lt dev test -- <spec>` (lt-projects) or `pnpm dlx playwright test <spec>` (non-lt). Backend Unit + API stay unrestricted — they're fast and catch cross-pillar regressions. Only run the full local Playwright suite when the user explicitly asks. Full recipe: `reference/e2e-testing.md` → "Local TDD Loop — Affected Specs Only".

## Ecosystem Context

Developers typically work in a **Lerna fullstack monorepo** created via `lt fullstack init`:

```
project/
├── projects/
│   ├── api/    ← nest-server-starter (depends on @lenne.tech/nest-server)
│   └── app/    ← nuxt-base-starter (depends on @lenne.tech/nuxt-extensions)
├── lerna.json
└── package.json (workspaces: ["projects/*"])
```

**Package relationships:**
- **nuxt-base-starter** (template) → depends on **@lenne.tech/nuxt-extensions** (plugin)
- **@lenne.tech/nuxt-extensions** provides pre-built composables, components, and types aligned with `@lenne.tech/nest-server`
- This skill covers `projects/app/` and any code using nuxt-base-starter or nuxt-extensions

## When to Use This Skill

- Working with Nuxt 4 projects (nuxt.config.ts present)
- Editing files in `app/components/`, `app/composables/`, `app/pages/`, `app/interfaces/`
- Creating or modifying Vue components with Nuxt UI
- Integrating backend APIs via generated types (`types.gen.ts`, `sdk.gen.ts`)
- Building forms with Valibot validation
- Implementing authentication (login, register, 2FA, passkeys)
- Working in monorepos with `projects/app/` or `packages/app/` structure

**NOT for:** NestJS backend development (use `generating-nest-servers` skill instead)

## Framework Source Files (MUST READ before guessing)

**ALWAYS read actual source code** from `node_modules/@lenne.tech/nuxt-extensions/` before guessing framework behavior. The framework ships documentation with the npm package.

| File (in `node_modules/@lenne.tech/nuxt-extensions/`) | When to Read |
|-------------------------------------------------------|-------------|
| `CLAUDE.md` | Start of any frontend task — composables, components, config |
| `dist/runtime/composables/` | Available composables (`useLtAuth`, `useLtAuthClient`, `useLtTusUpload`, `useLtFile`, `useLtShare`, `useLtErrorTranslation`, and from 1.7.0 the `useLtAi*` family) |
| `dist/runtime/components/` | Available components |
| `dist/runtime/utils/` | Available utilities |
| `dist/runtime/types/` | TypeScript type definitions |

**Also read** the nuxt-base-starter documentation:
- `README.md` — Project overview, tech stack, auth setup
- `AUTH.md` — Better Auth integration details

## CRITICAL: Real Backend Integration FIRST

**Never use placeholder data, TODO comments, or manual interfaces!**

- Always use real API calls via `sdk.gen.ts` from the start
- Always use generated types from `types.gen.ts` (never manual interfaces for DTOs)
- Run `pnpm run generate-types` with API running before starting frontend work
- Implement feature-by-feature with full backend integration

**Before starting:** Ensure services are running. See [reference/service-health-check.md](${CLAUDE_SKILL_DIR}/reference/service-health-check.md)

## Skill Boundaries

| User Intent | Correct Skill |
|------------|---------------|
| "Build a Vue component" | **THIS SKILL** |
| "Create a Nuxt page" | **THIS SKILL** |
| "Style with TailwindCSS" | **THIS SKILL** |
| "Create a NestJS module" | generating-nest-servers |
| "Security audit of frontend" | general-frontend-security |
| "Implement with TDD" | building-stories-with-tdd |

## Related Skills

**Works closely with:**
- `generating-nest-servers` - For NestJS backend development (projects/api/)
- `using-lt-cli` - For Git operations and Fullstack initialization
- `building-stories-with-tdd` - For complete TDD workflow (Backend + Frontend)
- `contributing-to-lt-framework` - When modifying `@lenne.tech/nuxt-extensions` itself and testing via `pnpm link`
- `/lt-dev:frontend:env-migrate` - Migrate env variables to `NUXT_` prefix convention

## Dev Server Lifecycle

When starting the App for manual testing, Chrome DevTools MCP debugging, or E2E tests: **prefer `lt dev up`** over `nuxt dev` directly. It serves the App under a stable HTTPS URL (`https://<slug>.localhost`) via Caddy, sets `NUXT_API_URL`/`NUXT_PUBLIC_SITE_URL`/`NUXT_PUBLIC_STORAGE_PREFIX`/`NUXT_PUBLIC_API_PROXY=false` automatically, and detaches into `<root>/.lt-dev/app.log`. Stop with `lt dev down`. For non-lt-projects (or when explicitly requested): use `run_in_background: true` and `pkill -f "nuxt dev"` afterwards. Leaving dev servers orphaned blocks the Claude Code session ("Unfurling..."). Full rules: `managing-dev-servers` skill.

**In monorepo projects:**
- `projects/app/` or `packages/app/` → **This skill**
- `projects/api/` or `packages/api/` → `generating-nest-servers` skill

## Nuxt 4 Directory Structure

```
app/                  # Application code (srcDir)
├── components/       # Auto-imported components
├── composables/      # Auto-imported composables
├── interfaces/       # TypeScript interfaces
├── lib/              # Utility libraries (auth-client, etc.)
├── pages/            # File-based routing
├── layouts/          # Layout components
├── utils/            # Auto-imported utilities
└── api-client/       # Generated types & SDK
server/               # Nitro server routes
public/               # Static assets
nuxt.config.ts
```

## Type Rules

| Priority | Source | Use For |
|----------|--------|---------|
| 1. | `~/api-client/types.gen.ts` | All backend DTOs (REQUIRED) |
| 2. | `~/api-client/sdk.gen.ts` | All API calls (REQUIRED) |
| 3. | Nuxt UI types | Component props (auto-imported) |
| 4. | `app/interfaces/*.interface.ts` | Frontend-only types (UI state, forms) |

## Standards

| Rule | Value |
|------|-------|
| UI Labels | Match the project's language — detect, never assume (see Gotchas) |
| Code/Comments | English |
| Styling | TailwindCSS only, no `<style>` |
| Colors | Semantic only (`primary`, `error`, `success`) |
| Types | Explicit, no implicit `any` |
| Backend Types | **Generated only** (`types.gen.ts`) |
| Composables | `app/composables/use*.ts` |
| Shared State | `useState()` for SSR-safe state |
| Local State | `ref()` / `reactive()` |
| Forms | Valibot (not Zod) |
| Modals | `useOverlay()` |

## Build Identity / Drift Detection

The starter ships `/app/admin/system` + `useSystem()` to show which build runs
and detect a drifted / stale deployment (App vs. API on different commits):

- App build is baked at build time into `runtimeConfig.public.appVersion` /
  `appCommit` (nuxt.config reads `package.json` version + `process.env.APP_VERSION_COMMIT`).
- API build is fetched from the public `GET /meta` via `buildLtApiUrl('/meta')`
  (auto-imported, SSR/proxy-aware) — never hardcode the API URL.
- Compare by **commit** only (`buildsMatch`); version numbers are per-component
  and may legitimately differ. `'unknown'` commits never trigger the warning.
- The Docker build must pass `APP_VERSION_COMMIT` (= CI commit SHA) *before*
  `nuxt build`, because `runtimeConfig.public` is frozen at build time.

## TDD for Frontend

```
1. Backend API must be complete (API tests pass)
2. Write E2E tests BEFORE implementing frontend
3. Implement components/pages until E2E tests pass
4. Debug with Chrome DevTools MCP
```

**Complete E2E testing guide: [reference/e2e-testing.md](${CLAUDE_SKILL_DIR}/reference/e2e-testing.md)**

## Error Handling — Consume Backend ErrorCodes via `useLtErrorTranslation`

The backend returns structured errors in the format `#LTNS_XXXX: Developer message` (core) or `#PROJ_XXXX: ...` (project-specific). The `@lenne.tech/nuxt-extensions` package ships `useLtErrorTranslation()` which parses the `#CODE:` marker, loads locale-specific translations from `GET /i18n/errors/:locale`, and returns end-user messages.

**NEVER assert or display raw English backend messages in the UI.** Always pipe errors through `translateError()` / `showErrorToast()` so users see localized text.

```vue
<script setup lang="ts">
const { translateError, showErrorToast, parseError } = useLtErrorTranslation();
const toast = useToast();

async function onSubmit() {
  try {
    await $fetch('/api/users', { method: 'POST', body: form.value });
  } catch (error) {
    // Preferred — direct toast from translated message
    showErrorToast(error, 'Speichern fehlgeschlagen');

    // Or manual, if you need more control
    toast.add({
      color: 'error',
      title: 'Speichern fehlgeschlagen',
      description: translateError(error),  // '#LTNS_0400: Resource not found' → 'Ressource nicht gefunden.'
    });

    // Or parse for custom handling (e.g. redirect on specific code)
    const parsed = parseError(error);
    if (parsed.code === 'LTNS_0023') {
      await navigateTo('/auth/verify-email');
    }
  }
}
</script>
```

**Rules:**
- [ ] Every error-handling site uses `useLtErrorTranslation()` — no raw `error.message` in Toast descriptions, form errors, or page-level error UI
- [ ] `loadTranslations(locale)` is called once at app start or on locale change (the composable caches per locale via `useState`)
- [ ] Code-based branching (`if (parsed.code === 'LTNS_XXXX')`) for flow-control decisions (verification-required redirects, retry prompts) — never branch on message-string contents
- [ ] Toast titles are hardcoded in the project's UI language (context-specific, e.g. German `'Anmeldung fehlgeschlagen'` or the project's English equivalent); descriptions come from `translateError`
- [ ] Tests assert translated messages (not English `error.message`) — see the test-reviewer rules in this plugin

**Full consumer reference: [reference/error-translation.md](${CLAUDE_SKILL_DIR}/reference/error-translation.md)**

## Reference Files

| Topic | File |
|-------|------|
| Core Patterns | [reference/patterns.md](${CLAUDE_SKILL_DIR}/reference/patterns.md) |
| Service Health Check | [reference/service-health-check.md](${CLAUDE_SKILL_DIR}/reference/service-health-check.md) |
| Browser Testing | [reference/browser-testing.md](${CLAUDE_SKILL_DIR}/reference/browser-testing.md) |
| TypeScript | [reference/typescript.md](${CLAUDE_SKILL_DIR}/reference/typescript.md) |
| Components | [reference/components.md](${CLAUDE_SKILL_DIR}/reference/components.md) |
| Composables | [reference/composables.md](${CLAUDE_SKILL_DIR}/reference/composables.md) |
| Forms | [reference/forms.md](${CLAUDE_SKILL_DIR}/reference/forms.md) |
| Modals | [reference/modals.md](${CLAUDE_SKILL_DIR}/reference/modals.md) |
| API | [reference/api.md](${CLAUDE_SKILL_DIR}/reference/api.md) |
| Colors | [reference/colors.md](${CLAUDE_SKILL_DIR}/reference/colors.md) |
| Nuxt Patterns | [reference/nuxt.md](${CLAUDE_SKILL_DIR}/reference/nuxt.md) |
| Authentication | [reference/authentication.md](${CLAUDE_SKILL_DIR}/reference/authentication.md) |
| E2E Testing | [reference/e2e-testing.md](${CLAUDE_SKILL_DIR}/reference/e2e-testing.md) |
| Troubleshooting | [reference/troubleshooting.md](${CLAUDE_SKILL_DIR}/reference/troubleshooting.md) |
| Security | [reference/security.md](${CLAUDE_SKILL_DIR}/reference/security.md) |
| Error Translation (consume backend ErrorCodes) | [reference/error-translation.md](${CLAUDE_SKILL_DIR}/reference/error-translation.md) |
| Informed Trade-offs (Composition API, readonly, SSR guards, v-html, useFetch) | [reference/informed-trade-off-pattern.md](${CLAUDE_SKILL_DIR}/reference/informed-trade-off-pattern.md) |

## Pre-Commit Checklist

- [ ] No placeholder data, no TODO comments for API
- [ ] All API calls via `sdk.gen.ts`, all types from `types.gen.ts`
- [ ] Logic in composables, modals use `useOverlay`, forms use Valibot
- [ ] TailwindCSS only, semantic colors only
- [ ] UI text matches the project's detected language (not assumed German), code/comments English, no implicit `any`
- [ ] Auth uses `useLtAuth()`, protected routes use `middleware: 'auth'`
- [ ] AI chat uses `useLtAiChat().stop()` for clean abort (NEVER raw `AbortController.abort()` on a `useLtAi*` stream — the composable does cleanup and treats AbortError as a clean stop)
- [ ] `LtAiPromptInput` (CRUD for `useLtAiPrompts`) vs `LtAiPromptRunInput` (execution payload for `useLtAi.prompt()` / `.promptStream()`) — never conflate; pre-1.7.0 they collided as one name and TypeScript silently merged them
- [ ] No `v-html` with user content, tokens stored securely
- [ ] All error-handling sites route through `useLtErrorTranslation()` — no raw backend messages in Toasts / UI
- [ ] Security review passed (`/lt-dev:review` for general scan)
- [ ] Feature tested in browser (Chrome DevTools MCP), no console errors

