← Back to skills
extension
Category: Development & EngineeringAPI key requirement unconfirmed

developing-lt-frontend

Handles ALL Nuxt 4 and Vue frontend development tasks including composables, forms (Valibot), API integration (types.gen.ts, sdk.gen.ts), authentication (Better Auth), SSR, and Playwright E2E testing. Supports monorepos (projects/app/, packages/app/). Activates when working with .vue files, nuxt.config.ts, Nuxt UI, TailwindCSS, or files in app/components/, app/composables/, app/pages/, app/interfaces/, app/layouts/. NOT for NestJS backend (use generating-nest-servers). NOT for security theory (use general-frontend-security).

personAuthor: jakexiaohubgithub

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 instead of assuming 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 (read before guessing)

Read the actual source code in 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

Real Backend Integration First

Build against the real API from the start: no 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

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() |

Design Without a Mockup

A Figma design or an existing page is the design source whenever one exists. Without either, build from Nuxt UI's page and layout components (UPageHero, UPageSection, UPageCard, UPageCTA …) and the project theme's semantic colors; they give a consistent, finished look on their own.

When the user asks for a distinctive look of its own and you write custom Tailwind, leave out the styles the model falls back on by default, which make a page read as machine-made:

  • monospace labels (font-mono) for eyebrows, metadata or figures
  • small uppercase, widely tracked "eyebrow" labels above headings (uppercase tracking-widest text-xs)
  • pill shapes on buttons and label chips (rounded-full); buttons keep the theme's radius
  • serif display headlines and italic accent words inside headings
  • cream, off-white or beige page backgrounds
  • numbered section labels such as "01 / 02 / 03"
  • decorative gradient washes, blurred color blobs and pulsing status dots

Build the distinctive part from the project's own theme instead: its colors, type scale, spacing, imagery and layout. After the first version, check which of these crept in anyway and which other stock pattern took their place, and name that one too; a general instruction such as "avoid a generic look" only swaps one default for another. Measured 2026-09-25 on evals/quality/nuxt-landing-custom-design (Opus 5.5, four runs per arm): without lt-dev every page used monospace labels, uppercase eyebrows, serif headlines, pill chips and cream backgrounds; with only the semantic-color rule, monospace labels still appeared in every page; with this list none of the ten patterns appeared in any run, and no other stock pattern took their place.

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

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.

Raw English backend messages appear neither in the UI nor in test assertions. Always pipe errors through translateError() / showErrorToast() so users see localized text.

<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

Reference Files

| Topic | File | |-------|------| | Core Patterns | reference/patterns.md | | Service Health Check | reference/service-health-check.md | | Browser Testing | reference/browser-testing.md | | TypeScript | reference/typescript.md | | Components | reference/components.md | | Composables | reference/composables.md | | Forms | reference/forms.md | | Modals | reference/modals.md | | API | reference/api.md | | Colors | reference/colors.md | | Nuxt Patterns | reference/nuxt.md | | Authentication | reference/authentication.md | | E2E Testing | reference/e2e-testing.md | | Troubleshooting | reference/troubleshooting.md | | Security | reference/security.md | | Error Translation (consume backend ErrorCodes) | reference/error-translation.md | | Informed Trade-offs (Composition API, readonly, SSR guards, v-html, useFetch) | 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 (not a 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