Frontend Authentication Integration

Complete guide for integrating authentication in the Next.js frontend with API clients, state management, and protected routes

Frontend Authentication Integration

Complete guide for integrating authentication in the Next.js frontend.

API Clients

Auth Client

For allauth headless endpoints:

// lib/api-client.ts
export const authClient = Axios.create({
  baseURL: `${API_BASE_URL}/_allauth/browser/v1`,
  withCredentials: true,  // Send cookies
  headers: {
    "Content-Type": "application/json",
  },
});

// Add CSRF token to requests
authClient.interceptors.request.use((config) => {
  const csrfToken = getCsrfToken();
  if (csrfToken) {
    config.headers["X-CSRFToken"] = csrfToken;
  }
  return config;
});

Main API Client

For authenticated API requests:

export const apiClient = Axios.create({
  baseURL: `${API_BASE_URL}/api/v1`,
  withCredentials: true,
  headers: {
    "Content-Type": "application/json",
  },
});

Auth State Management

The useAuth hook provides all authentication functionality:

// hooks/use-auth.ts
export function useAuth() {
  return {
    // State
    user: User | null,
    isAuthenticated: boolean,
    isLoading: boolean,
    error: string | null,
    config: AuthConfig | null,
    pendingFlow: AuthFlow | null,
    
    // Actions
    login: (email: string, password: string) => Promise<void>,
    signup: (email: string, password: string) => Promise<void>,
    logout: () => Promise<void>,
    getSession: () => Promise<void>,
    getConfig: () => Promise<void>,
    requestPasswordReset: (email: string) => Promise<void>,
    resetPassword: (key: string, password: string) => Promise<void>,
    verifyEmail: (key: string) => Promise<void>,
    resendVerificationEmail: (email: string) => Promise<void>,
    authenticateMfa: (code: string) => Promise<void>,
    setupTotp: () => Promise<{ totp_url: string; secret: string }>,
    activateTotp: (code: string) => Promise<void>,
    socialLogin: (provider: string, callbackUrl: string) => void,
    clearError: () => void,
  };
}

Auth Layout

Protected route wrapper with authentication check:

// app/(auth)/layout.tsx
export default function AuthLayout({ children }) {
  const router = useRouter();
  const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    setMounted(true);
    useAuthStore.persist.rehydrate();
    useAuthStore.getState().getConfig();  // Establishes CSRF cookie
  }, []);

  useEffect(() => {
    if (mounted && isAuthenticated) {
      router.push("/projects");
    }
  }, [isAuthenticated, mounted, router]);

  if (!mounted || isAuthenticated) {
    return <LoadingSpinner />;
  }

  return <div className="auth-layout">{children}</div>;
}

Login Page

// app/(auth)/login/page.tsx
export default function LoginPage() {
  const router = useRouter();
  const { login, socialLogin, isLoading, error, pendingFlow, config } = useAuth();
  const [formData, setFormData] = useState({ email: "", password: "" });

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    try {
      await login(formData.email, formData.password);
      
      if (pendingFlow?.id === "mfa_authenticate") {
        router.push("/mfa/verify");
      } else {
        router.push("/projects");
      }
    } catch {
      // Error displayed from store
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <Input name="email" type="email" required />
      <Input name="password" type="password" required />
      {error && <p className="text-red-500">{error}</p>}
      <Button type="submit" disabled={isLoading}>
        {isLoading ? <Spinner /> : "Sign In"}
      </Button>
    </form>
  );
}

Protected Routes

App Layout

// app/(app)/layout.tsx
export default function AppLayout({ children }) {
  const { isAuthenticated, isLoading, getSession } = useAuth();
  const router = useRouter();

  useEffect(() => {
    getSession();
  }, []);

  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.push("/login");
    }
  }, [isLoading, isAuthenticated, router]);

  if (isLoading || !isAuthenticated) {
    return <LoadingSpinner />;
  }

  return <>{children}</>;
}

CSRF Token Handling

The frontend must:

  1. Fetch config first — This sets the CSRF cookie
  2. Include token in requests — Via X-CSRFToken header
// Get CSRF token from cookie
function getCsrfToken(): string | null {
  if (typeof document === "undefined") return null;
  const match = document.cookie.match(/csrftoken=([^;]+)/);
  return match ? match[1] : null;
}

Session Persistence

Auth state is persisted to localStorage via Zustand:

export const useAuthStore = create<AuthStore>()(
  persist(
    (set, get) => ({
      // ... state and actions
    }),
    {
      name: "auth-storage",
      storage: createJSONStorage(() => localStorage),
      partialize: (state) => ({
        user: state.user,
        isAuthenticated: state.isAuthenticated,
      }),
      skipHydration: true,  // Manual hydration on mount
    }
  )
);

Error Handling

const handleAuthError = (error: unknown): string => {
  if (error instanceof Error) {
    const axiosError = error as AxiosError<{
      detail?: string;
      errors?: { message: string }[];
    }>;
    
    if (axiosError.response?.data?.detail) {
      return axiosError.response.data.detail;
    }
    if (axiosError.response?.data?.errors?.[0]?.message) {
      return axiosError.response.data.errors[0].message;
    }
    return error.message;
  }
  return "An unexpected error occurred";
};

Type Definitions

export type User = {
  id: number;
  email: string;
  username?: string;
  display?: string;
  has_usable_password?: boolean;
};

export type AuthFlow = {
  id: string;
  is_pending?: boolean;
};

export type AuthConfig = {
  account: {
    authentication_method: string;
  };
  socialaccount: {
    providers: { id: string; name: string; flows: string[] }[];
  };
  mfa: {
    supported_types: string[];
  };
};

Testing

// hooks/use-auth.test.tsx
import { renderHook, act } from "@testing-library/react";
import { useAuth } from "./use-auth";

describe("useAuth", () => {
  it("should login successfully", async () => {
    const { result } = renderHook(() => useAuth());
    
    await act(async () => {
      await result.current.login("user@example.com", "password");
    });
    
    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user?.email).toBe("user@example.com");
  });
});

Last updated on

On this page