Skip to content

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>&#123;&#123; 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"
      >
        &#123;&#123; 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.vue

Key Integration Points

Frontend ActionBackend EndpointNotes
Read CSRF tokenCookie CSRF_TOKENSend as X-CSRF-TOKEN header
Check sessionGET /api/auth/meReturns user or 401
LoginPOST /api/auth/loginSets PARTIZAP_SESSION cookie
RegisterPOST /api/auth/registerSets session + sends verification email
Verify emailPOST /api/auth/verify-email6-digit code, expires in 15 min
Resend codePOST /api/auth/resend-verification60s cooldown between sends
Forgot passwordPOST /api/auth/forgot-passwordAlways returns success
Reset passwordPOST /api/auth/reset-passwordToken from email link, invalidates all sessions
LogoutPOST /api/auth/logoutDestroys current session
Logout allPOST /api/auth/logout-allDestroys all user sessions