Appearance
Guide: Implementing Auth Pages in Nuxt
Step-by-step guide for building registration, login, email verification, and password reset pages in Nuxt 4, connected to the Partizap PHP backend.
Prerequisites
- Nuxt 4 project initialized with Nuxt UI v4
- Backend running at
/api(via nginx proxy) - Pinia installed for auth store
Step 1: API Client with CSRF
Create a $fetch wrapper that handles CSRF tokens and credentials.
app/shared/api/client.ts
ts
export const useApiClient = () => {
const csrfToken = useCookie('CSRF_TOKEN');
const config = useRuntimeConfig();
return $fetch.create({
baseURL: config.public.apiBase,
credentials: 'include',
onRequest({ options }) {
const method = (options.method ?? 'GET').toUpperCase();
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
options.headers.set('X-CSRF-TOKEN', csrfToken.value ?? '');
}
},
onResponseError({ response }) {
if (response.status === 401) {
const authStore = useAuthStore();
authStore.clearUser();
navigateTo('/auth/login');
}
},
});
};nuxt.config.ts — runtime config:
ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
apiBase: '/api',
},
},
});Step 2: Auth Store (Pinia)
app/stores/auth.ts
ts
import { defineStore } from 'pinia';
interface User {
id: number;
email: string;
display_name: string;
phone: string | null;
account_type: 'personal' | 'business';
email_verified: boolean;
avatar_url: string | null;
city_id: number | null;
district_id: number | null;
metro_station_id: number | null;
rating: string;
reviews_count: number;
products_count: number;
is_active: boolean;
is_admin: boolean;
created_at: string;
}
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
loading: false,
}),
getters: {
isAuthenticated: (state) => state.user !== null,
isEmailVerified: (state) => state.user?.email_verified ?? false,
isAdmin: (state) => state.user?.is_admin ?? false,
},
actions: {
setUser(user: User) {
this.user = user;
},
clearUser() {
this.user = null;
},
async fetchUser() {
const api = useApiClient();
try {
const { data } = await api<{ data: User }>('/auth/me');
this.user = data;
} catch {
this.user = null;
}
},
async login(email: string, password: string) {
const api = useApiClient();
const { data } = await api<{ data: User }>('/auth/login', {
method: 'POST',
body: { email, password },
});
this.user = data;
},
async register(email: string, password: string, displayName: string) {
const api = useApiClient();
const { data } = await api<{ data: User }>('/auth/register', {
method: 'POST',
body: { email, password, display_name: displayName },
});
this.user = data;
},
async logout() {
const api = useApiClient();
await api('/auth/logout', { method: 'POST' });
this.user = null;
},
},
});Step 3: Auth Middleware
app/middleware/auth.ts — protect routes requiring login:
ts
export default defineNuxtRouteMiddleware(async () => {
const authStore = useAuthStore();
if (!authStore.isAuthenticated) {
await authStore.fetchUser();
}
if (!authStore.isAuthenticated) {
return navigateTo('/auth/login');
}
});app/middleware/guest.ts — redirect logged-in users away from auth pages:
ts
export default defineNuxtRouteMiddleware(async () => {
const authStore = useAuthStore();
if (!authStore.isAuthenticated) {
await authStore.fetchUser();
}
if (authStore.isAuthenticated) {
return navigateTo('/cabinet');
}
});Step 4: Zod Validation Schemas
app/shared/schemas/auth.ts
ts
import { z } from 'zod';
// Must match backend PasswordValidator rules
const passwordSchema = z
.string()
.min(8, 'Minimum 8 characters')
.max(128, 'Maximum 128 characters')
.regex(/[A-Z]/, 'At least one uppercase letter')
.regex(/[a-z]/, 'At least one lowercase letter')
.regex(/[0-9]/, 'At least one digit')
.regex(/[^a-zA-Z0-9]/, 'At least one special character')
.refine(
(val) => !/(.)\1{2,}/.test(val),
'No 3+ consecutive identical characters',
);
export const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(1, 'Password is required'),
});
export const registerSchema = z.object({
email: z.string().email('Invalid email'),
password: passwordSchema,
display_name: z
.string()
.min(1, 'Name is required')
.max(100, 'Maximum 100 characters'),
});
export const forgotPasswordSchema = z.object({
email: z.string().email('Invalid email'),
});
export const resetPasswordSchema = z.object({
token: z.string().min(1),
password: passwordSchema,
});
export const verifyEmailSchema = z.object({
code: z
.string()
.length(6, 'Code must be 6 digits')
.regex(/^\d+$/, 'Digits only'),
});Step 5: Error Handling Composable
app/shared/composables/useApiError.ts
ts
import type { FetchError } from 'ofetch';
interface ApiError {
error: {
code: string;
message: string;
details?: Record<string, string[]>;
};
}
export const useApiError = () => {
const globalError = ref<string | null>(null);
const fieldErrors = ref<Record<string, string>>({});
function handleError(err: unknown) {
globalError.value = null;
fieldErrors.value = {};
const fetchError = err as FetchError<ApiError>;
const apiError = fetchError.data?.error;
if (!apiError) {
globalError.value = 'Connection error. Try again.';
return;
}
// Validation errors — map to form fields
if (fetchError.status === 422 && apiError.details) {
for (const [field, messages] of Object.entries(apiError.details)) {
fieldErrors.value[field] = messages[0];
}
return;
}
// Rate limit
if (fetchError.status === 429) {
const retryAfter = fetchError.response?.headers.get('Retry-After');
const seconds = retryAfter ? parseInt(retryAfter) : 60;
globalError.value = `Too many attempts. Try again in ${Math.ceil(seconds / 60)} min.`;
return;
}
// Everything else (401, 409, 500, etc.)
globalError.value = apiError.message;
}
function clearErrors() {
globalError.value = null;
fieldErrors.value = {};
}
return { globalError, fieldErrors, handleError, clearErrors };
};Step 6: Login Page
app/pages/auth/login.vue
html
<script setup lang="ts">
definePageMeta({
middleware: 'guest',
layout: 'default',
});
const authStore = useAuthStore();
const { globalError, handleError, clearErrors } = useApiError();
const loading = ref(false);
const state = reactive({
email: '',
password: '',
});
async function onSubmit() {
clearErrors();
loading.value = true;
try {
await authStore.login(state.email, state.password);
await navigateTo('/cabinet');
} catch (err) {
handleError(err);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-sm mt-16">
<h1 class="text-2xl font-bold mb-6">Login</h1>
<UAlert
v-if="globalError"
color="error"
:title="globalError"
class="mb-4"
/>
<UForm :schema="loginSchema" :state="state" @submit="onSubmit">
<UFormField label="Email" name="email" class="mb-4">
<UInput v-model="state.email" type="email" placeholder="email@example.com" />
</UFormField>
<UFormField label="Password" name="password" class="mb-4">
<UInput v-model="state.password" type="password" />
</UFormField>
<UButton type="submit" block :loading="loading">
Login
</UButton>
</UForm>
<div class="mt-4 text-sm text-center space-y-2">
<NuxtLink to="/auth/forgot-password" class="text-primary">
Forgot password?
</NuxtLink>
<p>
No account?
<NuxtLink to="/auth/register" class="text-primary">Register</NuxtLink>
</p>
</div>
</div>
</template>Step 7: Registration Page
app/pages/auth/register.vue
html
<script setup lang="ts">
definePageMeta({
middleware: 'guest',
layout: 'default',
});
const authStore = useAuthStore();
const { globalError, handleError, clearErrors } = useApiError();
const loading = ref(false);
const state = reactive({
email: '',
password: '',
display_name: '',
});
async function onSubmit() {
clearErrors();
loading.value = true;
try {
await authStore.register(state.email, state.password, state.display_name);
// After registration, redirect to email verification
await navigateTo('/auth/verify-email');
} catch (err) {
handleError(err);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-sm mt-16">
<h1 class="text-2xl font-bold mb-6">Register</h1>
<UAlert
v-if="globalError"
color="error"
:title="globalError"
class="mb-4"
/>
<UForm :schema="registerSchema" :state="state" @submit="onSubmit">
<UFormField label="Name" name="display_name" class="mb-4">
<UInput v-model="state.display_name" placeholder="John Doe" />
</UFormField>
<UFormField label="Email" name="email" class="mb-4">
<UInput v-model="state.email" type="email" placeholder="email@example.com" />
</UFormField>
<UFormField label="Password" name="password" class="mb-4" hint="Min 8 chars, uppercase, lowercase, digit, special char">
<UInput v-model="state.password" type="password" />
</UFormField>
<UButton type="submit" block :loading="loading">
Register
</UButton>
</UForm>
<p class="mt-4 text-sm text-center">
Already have an account?
<NuxtLink to="/auth/login" class="text-primary">Login</NuxtLink>
</p>
</div>
</template>Step 8: Email Verification Page
app/pages/auth/verify-email.vue
html
<script setup lang="ts">
definePageMeta({
middleware: 'auth',
layout: 'default',
});
const authStore = useAuthStore();
const api = useApiClient();
const { globalError, handleError, clearErrors } = useApiError();
const loading = ref(false);
const resending = ref(false);
const resendCooldown = ref(0);
let cooldownInterval: ReturnType<typeof setInterval>;
const state = reactive({
code: '',
});
// Redirect if already verified
if (authStore.isEmailVerified) {
navigateTo('/cabinet');
}
async function onSubmit() {
clearErrors();
loading.value = true;
try {
await api('/auth/verify-email', {
method: 'POST',
body: { code: state.code },
});
await authStore.fetchUser();
await navigateTo('/cabinet');
} catch (err) {
handleError(err);
} finally {
loading.value = false;
}
}
async function resendCode() {
clearErrors();
resending.value = true;
try {
await api('/auth/resend-verification', { method: 'POST' });
// Start 60-second cooldown
resendCooldown.value = 60;
cooldownInterval = setInterval(() => {
resendCooldown.value--;
if (resendCooldown.value <= 0) clearInterval(cooldownInterval);
}, 1000);
} catch (err) {
handleError(err);
} finally {
resending.value = false;
}
}
onUnmounted(() => clearInterval(cooldownInterval));
</script>
<template>
<div class="mx-auto max-w-sm mt-16">
<h1 class="text-2xl font-bold mb-2">Verify Email</h1>
<p class="text-gray-500 mb-6">
Enter the 6-digit code sent to <strong>{{ authStore.user?.email }}</strong>
</p>
<UAlert
v-if="globalError"
color="error"
:title="globalError"
class="mb-4"
/>
<UForm :schema="verifyEmailSchema" :state="state" @submit="onSubmit">
<UFormField label="Verification code" name="code" class="mb-4">
<UInput
v-model="state.code"
placeholder="123456"
maxlength="6"
inputmode="numeric"
/>
</UFormField>
<UButton type="submit" block :loading="loading">
Verify
</UButton>
</UForm>
<div class="mt-4 text-center">
<UButton
variant="ghost"
:disabled="resendCooldown > 0"
:loading="resending"
@click="resendCode"
>
{{ resendCooldown > 0 ? `Resend in ${resendCooldown}s` : 'Resend code' }}
</UButton>
</div>
</div>
</template>Step 9: Forgot Password Page
app/pages/auth/forgot-password.vue
html
<script setup lang="ts">
definePageMeta({
middleware: 'guest',
layout: 'default',
});
const api = useApiClient();
const { globalError, handleError, clearErrors } = useApiError();
const loading = ref(false);
const sent = ref(false);
const state = reactive({
email: '',
});
async function onSubmit() {
clearErrors();
loading.value = true;
try {
await api('/auth/forgot-password', {
method: 'POST',
body: { email: state.email },
});
sent.value = true;
} catch (err) {
handleError(err);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-sm mt-16">
<h1 class="text-2xl font-bold mb-6">Reset Password</h1>
<template v-if="sent">
<UAlert
color="success"
title="If this email is registered, a reset link has been sent."
class="mb-4"
/>
<NuxtLink to="/auth/login" class="text-primary text-sm">
Back to login
</NuxtLink>
</template>
<template v-else>
<UAlert
v-if="globalError"
color="error"
:title="globalError"
class="mb-4"
/>
<UForm :schema="forgotPasswordSchema" :state="state" @submit="onSubmit">
<UFormField label="Email" name="email" class="mb-4">
<UInput v-model="state.email" type="email" placeholder="email@example.com" />
</UFormField>
<UButton type="submit" block :loading="loading">
Send Reset Link
</UButton>
</UForm>
</template>
</div>
</template>Step 10: Reset Password Page
The user arrives here from an email link like /auth/reset-password?token=abc123.
app/pages/auth/reset-password.vue
html
<script setup lang="ts">
definePageMeta({
middleware: 'guest',
layout: 'default',
});
const route = useRoute();
const api = useApiClient();
const { globalError, handleError, clearErrors } = useApiError();
const loading = ref(false);
const success = ref(false);
const token = route.query.token as string;
if (!token) {
navigateTo('/auth/forgot-password');
}
const state = reactive({
password: '',
});
async function onSubmit() {
clearErrors();
loading.value = true;
try {
await api('/auth/reset-password', {
method: 'POST',
body: { token, password: state.password },
});
success.value = true;
} catch (err) {
handleError(err);
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-sm mt-16">
<h1 class="text-2xl font-bold mb-6">Set New Password</h1>
<template v-if="success">
<UAlert
color="success"
title="Password reset successfully. You can now log in."
class="mb-4"
/>
<NuxtLink to="/auth/login" class="text-primary text-sm">
Go to login
</NuxtLink>
</template>
<template v-else>
<UAlert
v-if="globalError"
color="error"
:title="globalError"
class="mb-4"
/>
<UForm :state="state" @submit="onSubmit">
<UFormField
label="New password"
name="password"
class="mb-4"
hint="Min 8 chars, uppercase, lowercase, digit, special char"
>
<UInput v-model="state.password" type="password" />
</UFormField>
<UButton type="submit" block :loading="loading">
Reset Password
</UButton>
</UForm>
</template>
</div>
</template>Step 11: Fetch User on App Init
app/plugins/auth.ts
ts
export default defineNuxtPlugin(async () => {
const authStore = useAuthStore();
// On SSR and first client load, check if user is logged in
if (import.meta.server || !authStore.isAuthenticated) {
await authStore.fetchUser();
}
});This calls GET /api/auth/me on every page load (SSR) to hydrate the auth state. If the session cookie is valid, the user is set; otherwise user stays null.
Step 12: Logout
Add a logout button to the header/sidebar:
html
<script setup>
const authStore = useAuthStore();
async function logout() {
await authStore.logout();
await navigateTo('/auth/login');
}
</script>
<template>
<UButton v-if="authStore.isAuthenticated" variant="ghost" @click="logout">
Logout
</UButton>
</template>Summary of Files
app/
├── shared/
│ ├── api/client.ts — $fetch wrapper with CSRF
│ ├── schemas/auth.ts — Zod validation schemas
│ └── composables/useApiError.ts — API error handler
├── stores/auth.ts — Pinia auth store
├── middleware/
│ ├── auth.ts — Require login
│ └── guest.ts — Redirect if logged in
├── plugins/auth.ts — Hydrate user on init
└── pages/auth/
├── login.vue
├── register.vue
├── verify-email.vue
├── forgot-password.vue
└── reset-password.vueKey Integration Points
| Frontend Action | Backend Endpoint | Notes |
|---|---|---|
| Read CSRF token | Cookie CSRF_TOKEN | Send as X-CSRF-TOKEN header |
| Check session | GET /api/auth/me | Returns user or 401 |
| Login | POST /api/auth/login | Sets PARTIZAP_SESSION cookie |
| Register | POST /api/auth/register | Sets session + sends verification email |
| Verify email | POST /api/auth/verify-email | 6-digit code, expires in 15 min |
| Resend code | POST /api/auth/resend-verification | 60s cooldown between sends |
| Forgot password | POST /api/auth/forgot-password | Always returns success |
| Reset password | POST /api/auth/reset-password | Token from email link, invalidates all sessions |
| Logout | POST /api/auth/logout | Destroys current session |
| Logout all | POST /api/auth/logout-all | Destroys all user sessions |