Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects.
Want to start a new fullstack project?
Use the nuxt-base-starter (Frontend) together with the nest-server-starter (Backend) to initialize a complete fullstack project with this package pre-configured.
Both starters are designed to work seamlessly together and serve as reference implementations showing how to use all features in a real application.
Quick initialization:
npx @lenne.tech/cli fullstack init my-project
| Dependency | Required version | Notes |
|---|---|---|
| Node | >= 22 |
|
| Nuxt | ^4.0.0 |
|
better-auth |
>=1.7.1 <1.8.0 |
required peer — pin it, see below |
@better-auth/passkey |
>=1.7.1 <1.8.0 |
optional peer, passkey/WebAuthn only |
tus-js-client |
>=4.0.0 |
optional peer, resumable uploads only |
@lenne.tech/nest-server |
>= 11.37.0 |
only if you use the backend — see the lock-step rule |
Why better-auth is pinned to one minor line, and why you must not widen it:
better-auth is one protocol with two ends. This module is the client end and
@lenne.tech/nest-server is the server end, and they must resolve the same
better-auth minor. better-auth breaks in minor releases — 1.7 removed subpath
exports and changed the 2FA response shape — so a caret (^1.7.1) would re-open the
hole at 1.8. If the api and the app drift apart, 2FA activation fails at runtime with a
generic client error while both projects' checks stay green: each is internally
consistent, and only the assembled workspace has both halves.
Raising better-auth therefore means raising it in @lenne.tech/nest-server and
@lenne.tech/nuxt-extensions in one step.
npm install @lenne.tech/nuxt-extensions better-auth@1.7.1
# Optional: For passkey support
npm install @better-auth/passkey@1.7.1
# Optional: For TUS file uploads
npm install tus-js-clientIn a fullstack monorepo, pin it workspace-wide so the api and the app cannot drift:
# pnpm-workspace.yaml
overrides:
better-auth: 1.7.1
'@better-auth/passkey': 1.7.1- Better-Auth Integration - Login, 2FA, Passkey/WebAuthn support
- Cookie/JWT Dual-Mode - Automatic fallback from cookies to JWT
- TUS File Upload - Resumable uploads with pause/resume
- Transition Components - Ready-to-use Vue transition wrappers
- i18n Support - English and German translations (works without i18n too)
- Auto-imports - All composables and components are auto-imported
- Pre-hydration input preservation - text typed before hydration is kept, not erased
- Form label repair - restores
<label for>associations that hydration breaks
| Variable | Context | Description |
|---|---|---|
NUXT_PUBLIC_API_URL |
Client + Server | Public API URL. Primary way to configure the API endpoint. Used for client-side requests and as SSR fallback. |
NUXT_API_URL |
Server only | Internal API URL for SSR requests. Use when the backend has a private network address that should not be exposed to the client. |
NUXT_PUBLIC_API_PROXY |
Client | Set to true to enable the Vite dev proxy. Routes client requests through /api/ for same-origin cookies. Only for local development. |
All environment variables are resolved at runtime (not build time). This means you can build a Docker image once and deploy it to different environments by changing env vars — no rebuild needed.
SSR fallback chain:
NUXT_API_URL → NUXT_PUBLIC_API_URL → auth.baseURL (from nuxt.config.ts) → (unset)
Client fallback chain (no proxy):
NUXT_PUBLIC_API_URL → auth.baseURL (from nuxt.config.ts) → (unset)
Client with proxy (NUXT_PUBLIC_API_PROXY=true):
All requests go to /api/{path} — the Vite dev proxy forwards them to the backend.
No implicit
localhostdefault. If none of the sources above is set, there is no built-in fallback URL: API paths stay relative (e.g./iam/token) and resolve against the app origin, so auth and setup calls 404 unless the app is served behind a same-origin reverse proxy. The module logs a one-time warning ([LtExtensions] No API URL configured…) at app init and on the first API call. Always setNUXT_PUBLIC_API_URL(and optionallyNUXT_API_URLfor SSR).
Security:
NUXT_API_URLis never exposed to the client bundle. It stays inruntimeConfig.apiUrl(server only). This is important when using internal network addresses likehttp://api.svc.cluster.local.
Local development — Frontend and backend on different ports, proxy ensures same-origin cookies:
NUXT_PUBLIC_API_URL=http://localhost:3000
NUXT_PUBLIC_API_PROXY=trueProduction (simple) — Backend reachable via public URL from both SSR and client:
NUXT_PUBLIC_API_URL=https://api.example.comProduction (internal network) — SSR uses fast internal route, client uses public URL:
NUXT_PUBLIC_API_URL=https://api.example.com
NUXT_API_URL=http://api-internal:3000Legacy (nuxt.config.ts only) — Works but env vars are preferred for runtime flexibility:
// nuxt.config.ts
ltExtensions: {
auth: {
baseURL: 'https://api.example.com',
},
}// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@lenne.tech/nuxt-extensions'],
ltExtensions: {
// Auth configuration
auth: {
enabled: true, // Enable auth features
baseURL: '', // API base URL (empty = use env vars)
basePath: '/iam', // Better-Auth endpoint prefix
loginPath: '/auth/login', // Login redirect path
twoFactorRedirectPath: '/auth/2fa', // 2FA redirect path
// Plugin options
enableAdmin: true, // Admin plugin
enableTwoFactor: true, // 2FA plugin
enablePasskey: true, // Passkey/WebAuthn plugin
// Interceptor options
interceptor: {
enabled: true, // 401 auto-handler
publicPaths: ['/auth/login', '/auth/register'],
},
// System setup (first admin user creation)
systemSetup: {
enabled: false, // Enable setup flow
setupPath: '/auth/setup', // Setup page path
},
},
// Error translation configuration
errorTranslation: {
enabled: true, // Translate backend error codes
defaultLocale: 'de', // Fallback locale
},
// TUS upload configuration
tus: {
defaultEndpoint: '/files/upload',
defaultChunkSize: 5 * 1024 * 1024, // 5MB
},
// i18n configuration (optional)
i18n: {
autoMerge: true, // Auto-merge locales with @nuxtjs/i18n
},
// AI module configuration
ai: {
enabled: true, // Enable AI composables / auto-imports
basePath: '/ai', // Must match the nest-server AI controller
},
},
});Restores <label for> associations that hydration breaks. Active by default.
Nuxt UI's FormField derives the label's for and the control's id from a single
useId() call, so they cannot disagree — unless useId() itself returns different values on
server and client, which happens when the two walk a different number of async boundaries.
Measured against @nuxt/ui 4.11.x:
label for |
control id |
|
|---|---|---|
| SSR payload | v-0-4-2 |
v-0-4-2 |
| after hydration | v-0-4-2 |
v-0-1-2 |
Vue 3.5.39 did not cause this, it exposed it
(vuejs/core#9083 force-patches an element's
dynamic props on hydration). The control's id is such a prop; the label's for, passed
through reka-ui's Label component, is not — so only the label keeps the stale value.
The control loses its programmatic label. Without a placeholder it has no accessible name at
all and a screen reader announces "edit text, blank"; with a placeholder that becomes the
name instead, so the visible label is no longer part of it and speech input stops working.
Clicking the label focuses nothing either — which on a checkbox or radio is the primary hit
target, not a convenience. Tests using getByRole('textbox', { name }) stop finding fields.
After mount, and for a bounded window afterwards, three associations are repaired inside each Nuxt UI field whose label no longer resolves:
| Association | Repaired how | Why it needs its own path |
|---|---|---|
<label for> → control |
re-pointed at the field's control | for carries both the accessible name and click-to-focus |
aria-labelledby on a group |
caption id minted, group pointed at it | A radio group's id sits on a <div role="radiogroup">, and for only resolves against labelable elements — there it is inert however carefully it is chosen |
aria-describedby → error / help |
dangling tokens re-pointed, token-wise | The error container's id is force-patched on hydration; the reference to it is not. So the message is visible and never announced (WCAG 3.3.1 / 3.3.3) |
It refuses to guess. A repair that fires where it should not is worse than the defect: a
dangling label is inert and visible, a mis-pointed one is confidently wrong and silent, and on a
radio or checkbox a click on the caption would change a value the user never chose. So the for
repair only fires when the field contains exactly one eligible control. Skipped deliberately:
| Case | Why |
|---|---|
| Radio / checkbox groups | One field root, many controls — binding every caption to one item would submit a value nobody picked. Named via aria-labelledby instead |
Reka's submit proxy ([data-hidden] with no id) |
Not the user's control. The missing id is what identifies it — Nuxt UI's UFileUpload renders the real file input the same way, but stamps the field id on it |
Anything aria-hidden, hidden, type="hidden" or display:none |
Outside the accessibility tree, so a label pointing there would carry no name — the repair would report success and change nothing |
| A control that already has a working label | Multiple <label>s concatenate into one accessible name, so a second one produces a name matching no visible text |
| An id that is not unique in the document | for resolves through getElementById, which returns the first match — the label's click would go to the other element |
| Labels your application wrote | Only Nuxt UI's own field labels (data-slot="label") are touched |
disabled and readonly fields ARE repaired. They are rendered, stay in the accessibility
tree and are announced, so WCAG 1.3.1 / 4.1.2 apply unchanged — and WCAG's only carve-out for
inactive components is contrast (1.4.3 / 1.4.11); there is no naming exemption. For a disabled
field the repair restores the name only, since a disabled control prevents click dispatch and
is not focusable. On a read-only form that is the whole value: it is a page people read rather
than operate. Excluding disabled was tried in 1.13.0 and reproduced the very defect this plugin
repairs on every read-only form.
This is a repair, not a cure. The real fix is for the id not to diverge; this exists because the divergence sits in the framework stack rather than in any one application.
export default defineNuxtConfig({
ltExtensions: {
formLabelAssociation: {
enabled: false,
// Or keep it on and tune the two knobs:
// maxRepairMs: 3000, // window for deferred subtrees; clamped to [0, 30000]
// observeDeferred: false, // drop the MutationObserver for hydrate-on-visible content
},
},
});Two real reasons to turn it off: your application assigns for itself and depends on those exact
values, or your tests assert on literal for / id strings rather than on the accessible name.
Fix the second reason instead — assert on the accessible name, which is what a user perceives.
- A
for,aria-labelledbyoraria-describedbythat changes after mount is this plugin. In development it logs once per page, naming the fields it repaired. - Repaired labels carry
data-lt-label-repaired, so an E2E suite can assert the repair count is zero once the upstream divergence is fixed — and delete the plugin at that point.
Text typed before a page has hydrated is kept instead of being silently thrown away.
Until Vue hydrates a server-rendered <input>, the element carries no framework listener.
Text typed in that window is written to the DOM node, the input event reaches nothing, and
v-model's mounted hook then writes the model value back over it. The entry is erased, not
delayed — no error, no toast, nothing persisted.
It is load-dependent, so it hides in development and shows up on a cold cache, a slow device or a throttled CPU — most often on the login screen, where people type on sight.
Vue fixed this in 3.5.41 (vuejs/core#14411): on hydration it compares the field's live value against what the server rendered and, when they differ, adopts the typed value into the model instead of overwriting the node.
That adoption is gated on type="text" and textarea. Every other type still loses the
entry — and those are exactly the fields a sign-in form uses:
| Input type | Typed before hydration |
|---|---|
text, textarea |
kept by Vue itself |
email, password, tel, url, search, number |
erased |
Widening the gate is tracked upstream as
vuejs/core#15210 — open, labelled
p2-edge-case, no milestone.
A small client plugin that closes the remaining gap. Just before hydration it reads what is
in each field; just after, it writes back anything that was overwritten and dispatches a
synthetic input event so v-model adopts it.
A field counts as edited when its value differs from defaultValue — the same test Vue uses,
so values the server pre-filled are never mistaken for user input. A browser autofill that
lands before hydration is recovered the same way.
Fields stay ordinary editable fields throughout. Nothing is made readonly, so autofill,
screen-reader semantics and the mobile on-screen keyboard are untouched, and the user never
faces a field that looks usable and silently refuses.
This is a stopgap. When Vue covers the remaining types, delete it —
test/pre-hydration-input.test.ts pins the current gate and will fail on the types Vue takes
over, which is the signal.
export default defineNuxtConfig({
ltExtensions: {
preHydrationInput: {
enabled: false,
// or keep it on and only change how long deferred subtrees are waited for:
// maxRestoreMs: 3000,
},
},
});<script setup>
// Auth composable (auto-imported)
const {
user,
isAuthenticated,
isAdmin,
isLoading,
signIn,
signOut,
authenticateWithPasskey,
registerPasskey,
twoFactor,
} = useLtAuth();
// Login with email/password
async function handleLogin(email: string, password: string) {
const result = await signIn.email({ email, password });
if (result.requiresTwoFactor) {
navigateTo('/auth/2fa');
}
}
// Passkey login
async function handlePasskeyLogin() {
const result = await authenticateWithPasskey();
if (result.success) {
navigateTo('/dashboard');
}
}
</script>
<template>
<div v-if="isAuthenticated">
Welcome, {{ user?.name }}!
<button @click="signOut()">Logout</button>
</div>
</template>isAdmin accepts both user shapes, so the same frontend works against either backend:
| Backend | User shape | isAdmin is true when |
|---|---|---|
@lenne.tech/nest-server |
roles: ['admin'] — core Better-Auth additionalField (type: 'string[]'), no singular role |
roles contains 'admin' |
| Better-Auth admin plugin | role: 'admin' — single role |
role === 'admin' |
<script setup>
const { isAdmin } = useLtAuth();
</script>
<template>
<NuxtLink v-if="isAdmin" to="/admin">Admin</NuxtLink>
</template>
isAdminis a UX gate, not an authorization boundary. It reads the client-sidelt-auth-statecookie cache, which is not httpOnly and therefore user-writable. Use it to decide what to render; always enforce admin rights server-side (@Restricted(RoleEnum.ADMIN)in nest-server).The
auth.enableAdminoption toggles the Better-Auth admin client plugin (user-management calls such asadmin.listUsers()). It does not controlisAdmin— role detection works regardless of that flag.
For roles other than admin, use hasRole(role) / hasAnyRole(...roles). They apply the exact same both-shapes union and Array.isArray guard as isAdmin (which is itself just hasRole('admin')), so you never need the unguarded user.value?.roles?.includes(...):
<script setup>
const { hasRole, hasAnyRole } = useLtAuth();
</script>
<template>
<button v-if="hasRole('editor')">Edit</button>
<NuxtLink v-if="hasAnyRole('admin', 'editor')" to="/manage">Manage</NuxtLink>
</template>You can extend the auth client with additional Better Auth plugins:
Option 1: Plugin Registration (recommended)
Create a Nuxt plugin to register plugins before the auth client is initialized:
// plugins/auth-plugins.client.ts
import { registerLtAuthPlugins } from '@lenne.tech/nuxt-extensions/lib';
import { organizationClient, magicLinkClient } from 'better-auth/client/plugins';
export default defineNuxtPlugin(() => {
registerLtAuthPlugins([
organizationClient(),
magicLinkClient(),
]);
});Note: In Vue components,
registerLtAuthPluginsis auto-imported. In.tsfiles (like Nuxt plugins), import from@lenne.tech/nuxt-extensions/lib.
Option 2: Direct Factory Usage
For full control, create the auth client directly with your plugins:
import { createLtAuthClient } from '@lenne.tech/nuxt-extensions/lib';
import { organizationClient } from 'better-auth/client/plugins';
const authClient = createLtAuthClient({
plugins: [organizationClient()],
});
// Use authClient.organization.* methodsAvailable Better Auth Plugins:
organizationClient- Organization/team managementmagicLinkClient- Passwordless email loginoneTapClient- Google One Tap loginanonymousClient- Anonymous/guest sessions- See Better Auth Plugins for full list
<script setup>
const {
addFiles,
uploads,
totalProgress,
isUploading,
pauseUpload,
resumeUpload,
cancelUpload,
} = useLtTusUpload({
endpoint: '/api/files/upload',
onSuccess: (item) => console.log('Uploaded:', item.url),
onError: (item, error) => console.error('Failed:', error),
});
const { formatFileSize } = useLtFile();
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files) {
addFiles(Array.from(input.files));
}
}
</script>
<template>
<div>
<input type="file" multiple @change="handleFileSelect" />
<div v-for="upload in uploads" :key="upload.id">
<span>{{ upload.file.name }}</span>
<span>{{ formatFileSize(upload.progress.bytesUploaded) }} / {{ formatFileSize(upload.progress.bytesTotal) }}</span>
<span>{{ upload.progress.percentage }}%</span>
<button v-if="upload.status === 'uploading'" @click="pauseUpload(upload.id)">
Pause
</button>
<button v-if="upload.status === 'paused'" @click="resumeUpload(upload.id)">
Resume
</button>
</div>
<div v-if="isUploading">
Total Progress: {{ totalProgress.percentage }}%
</div>
</div>
</template><template>
<!-- Fade transition -->
<LtTransitionFade>
<div v-if="show">Content with fade</div>
</LtTransitionFade>
<!-- Fade with scale -->
<LtTransitionFadeScale :start-duration="200" :leave-duration="150">
<div v-if="show">Content with fade and scale</div>
</LtTransitionFadeScale>
<!-- Slide from right -->
<LtTransitionSlide>
<div v-if="show">Content slides from right</div>
</LtTransitionSlide>
<!-- Slide from bottom -->
<LtTransitionSlideBottom>
<div v-if="show">Content slides from bottom</div>
</LtTransitionSlideBottom>
<!-- Slide from left -->
<LtTransitionSlideRevert>
<div v-if="show">Content slides from left</div>
</LtTransitionSlideRevert>
</template><script setup>
const { share } = useLtShare();
async function handleShare() {
await share('Check this out!', 'Amazing content');
// Uses native share on mobile, clipboard fallback on desktop
}
</script>// Tailwind TypeScript helper
const buttonClasses = tw`bg-blue-500 hover:bg-blue-700 text-white`;
// Crypto utilities (for WebAuthn)
const hash = await ltSha256('password');
const base64 = ltArrayBufferToBase64Url(buffer);
const uint8 = ltBase64UrlToUint8Array(base64String);Headless, provider-agnostic composables for the @lenne.tech/nest-server AI module
(REST + SSE). All requests use the same auth-aware ltAuthFetch (Cookie/JWT) and URL
resolution as the rest of the library. Configure via ltExtensions.ai:
// nuxt.config.ts
ltExtensions: {
ai: { enabled: true, basePath: '/ai' }, // basePath must match the nest-server AI controller
}<script setup lang="ts">
const { budget, confirm, messages, requiresConfirmation, send, streaming } = useLtAiChat();
</script>useLtAiChat() streams the answer token-by-token, tracks the conversationId, exposes
the per-response budget summary, and drives the confirmation flow for mutating/
destructive actions (requiresConfirmation → confirm()).
<script setup lang="ts">
const { create, error, load, loading, prompts, remove, update } = useLtAiPrompts();
await load();
// create({ name: 'Summary', content: 'Summarize {{topic}}', scope: 'user' });
</script>useLtAiPrompts() lets any signed-in user manage re-usable prompt snippets. scope: 'user'
is private to the owner; scope: 'tenant' is shared across the owner's tenant. The list
returned by load() is already server-filtered to what the user is allowed to see.
| Composable | Purpose |
|---|---|
useLtAi() |
One-shot prompt(input) and streaming promptStream(input, handlers) (POST /ai/stream) |
useLtAiChat() |
Multi-turn chat state, streaming, budget, confirmation, stop()/clear(), optional maxMessages cap, auto-stop on component unmount |
useLtAiConnections() |
User self-service: available connections + select() (selected/locked) |
useLtAiUsage() |
Full token/prompt usage breakdown (GET /ai/usage) |
useLtAiPrompts() |
User-facing CRUD for re-usable prompt snippets ("Vorlagen", scope: 'user' / 'tenant') |
useLtAiPlaceholders() |
Loads {{placeholder}} registry from the backend so slot / prompt editors render a dynamic helper sidebar |
useLtAiAdmin() |
Admin CRUD: connections (+ detectCapabilities), preferences, budget-limits, slots (incl. listEffectiveSlots/resetSlot), prompt hints, interactions |
Helpers: buildLtAiUrl(path), ltAiRequest(method, path, body?), parseLtAiSseStream(response, onEvent, options?).
All AI DTOs are exported as LtAi* types. The streaming endpoint is consumed via a
fetch + ReadableStream SSE reader (not EventSource, since it is a POST with auth).
Security note:
useLtAiAdmin()is exposed as a regular composable; admin gating is enforced server-side (@Restricted(ADMIN)). Render admin UI behind a frontend route guard for UX, but trust the backend for authorization.
The package works with or without @nuxtjs/i18n:
| Setup | Language | Text Source |
|---|---|---|
| Without i18n | German | Hardcoded fallback texts |
With i18n, Locale: de |
German | From de.json |
With i18n, Locale: en |
English | From en.json |
| With i18n, other Locale | English | Fallback to en.json |
| Composable | Description |
|---|---|
useLtAuth() |
Better-Auth integration with session, passkey, 2FA |
useLtAuthClient() |
Direct access to the Better-Auth client singleton |
useLtErrorTranslation() |
Translate backend error codes to user-friendly messages |
useLtTusUpload() |
TUS protocol file uploads with pause/resume |
useLtFile() |
File utilities (size formatting, URLs) |
useLtShare() |
Web Share API with clipboard fallback |
useSystemSetup() |
System setup flow for initial admin user creation |
useLtAi() |
One-shot prompt() + streaming promptStream() for the nest-server AI module |
useLtAiChat() |
Multi-turn chat state, streaming, budget summary, confirmation gate, maxMessages cap |
useLtAiConnections() |
User self-service connection list + select() |
useLtAiUsage() |
Token/prompt usage breakdown per user / tenant |
useLtAiPrompts() |
User-facing prompt snippet CRUD ('user' / 'tenant' scope) |
useLtAiPlaceholders() |
Loads the backend's {{placeholder}} registry |
useLtAiAdmin() |
Admin CRUD for connections, preferences, budget limits, slots, prompt hints, interactions |
| Component | Description |
|---|---|
<LtTransitionFade> |
Opacity fade transition |
<LtTransitionFadeScale> |
Fade with scale transition |
<LtTransitionSlide> |
Slide from right transition |
<LtTransitionSlideBottom> |
Slide from bottom transition |
<LtTransitionSlideRevert> |
Slide from left transition |
| Utility | Description |
|---|---|
tw |
Tailwind TypeScript helper |
ltSha256() |
SHA256 hash function |
ltArrayBufferToBase64Url() |
ArrayBuffer to base64url conversion |
ltBase64UrlToUint8Array() |
Base64url to Uint8Array conversion |
createLtAuthClient() |
Auth client factory for custom configuration |
registerLtAuthPlugins() |
Register custom Better Auth plugins |
| Project | Description |
|---|---|
| nuxt-base-starter | Frontend starter template (uses this package) |
| nest-server-starter | Backend starter template (Better-Auth backend) |
| @lenne.tech/nest-server | Backend framework with Better-Auth support |
| @lenne.tech/cli | CLI tool for fullstack project initialization |
+-------------------------------------------------------------+
| Your Fullstack App |
+-----------------------------+-------------------------------+
| Frontend | Backend |
| (nuxt-base-starter) | (nest-server-starter) |
+-----------------------------+-------------------------------+
| @lenne.tech/nuxt-extensions| @lenne.tech/nest-server |
| - useLtAuth() | - CoreBetterAuthModule |
| - useLtTusUpload() | - CoreFileModule |
| - <LtTransition*> | - CoreUserModule |
+-----------------------------+-------------------------------+
# Install dependencies
npm install
# Generate type stubs
npm run dev:prepare
# Develop with playground
npm run dev
# Build the module
npm run build
# Run tests
npm run test