Skip to content

Frontend Documentation

This document describes the Next.js frontend architecture.

Technology Stack

Technology Version Purpose
Next.js 15.x React framework with App Router
React 19.x UI library
TypeScript 5.x Type safety
Tailwind CSS 4.x Styling
Zustand 5.x State management
TanStack Query 5.x Server state management
Bun Latest Package manager & runtime

Project Structure

Text Only
frontend/
├── src/
│   ├── app/                    # Next.js App Router
│   │   ├── layout.tsx          # Root layout
│   │   ├── page.tsx            # Home page
│   │   ├── providers.tsx       # Client providers
│   │   ├── (auth)/             # Auth route group
│   │   │   ├── login/
│   │   │   └── register/
│   │   ├── (dashboard)/        # Protected route group
│   │   │   ├── layout.tsx
│   │   │   ├── dashboard/
│   │   │   ├── chat/
│   │   │   └── profile/
│   │   └── api/                # API routes (BFF)
│   │       ├── health/
│   │       └── auth/
│   │           ├── login/
│   │           ├── logout/
│   │           ├── register/
│   │           ├── refresh/
│   │           └── me/
│   │
│   ├── components/             # React components
│   │   ├── ui/                 # Base UI components
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── card.tsx
│   │   │   └── ...
│   │   ├── layout/             # Layout components
│   │   │   ├── header.tsx
│   │   │   └── sidebar.tsx
│   │   ├── auth/               # Auth components
│   │   │   ├── login-form.tsx
│   │   │   └── register-form.tsx
│   │   ├── chat/               # Chat components
│   │   │   ├── chat-container.tsx
│   │   │   ├── chat-input.tsx
│   │   │   ├── message-list.tsx
│   │   │   └── message-item.tsx
│   │   └── theme/              # Theme components
│   │       ├── theme-provider.tsx
│   │       └── theme-toggle.tsx
│   │
│   ├── hooks/                  # Custom React hooks
│   │   ├── use-auth.ts
│   │   ├── use-websocket.ts
│   │   ├── use-chat.ts
│   │   └── use-conversations.ts
│   │
│   ├── stores/                 # Zustand stores
│   │   ├── auth-store.ts
│   │   ├── chat-store.ts
│   │   ├── conversation-store.ts
│   │   └── theme-store.ts
│   │
│   ├── lib/                    # Utilities
│   │   ├── api-client.ts       # Fetch wrapper
│   │   ├── server-api.ts       # Server-side API calls
│   │   ├── utils.ts            # Helper functions
│   │   └── constants.ts
│   │
│   ├── types/                  # TypeScript types
│   │   ├── api.ts
│   │   ├── auth.ts
│   │   ├── chat.ts
│   │   └── conversation.ts
│   │
│   └── middleware.ts           # Next.js middleware
├── e2e/                        # Playwright tests
│   ├── auth.setup.ts
│   ├── auth.spec.ts
│   ├── home.spec.ts
│   └── chat.spec.ts
├── messages/                   # i18n translations
│   ├── en.json
│   └── pl.json
├── public/                     # Static assets
├── package.json
├── tsconfig.json
├── tailwind.config.ts
├── playwright.config.ts
└── vitest.config.ts

Authentication

Overview

Authentication uses HTTP-only cookies for secure token storage:

  1. Login - Backend returns JWT tokens
  2. Cookies - Frontend API routes set HTTP-only cookies
  3. Requests - Cookies sent automatically with each request
  4. Refresh - Automatic token refresh before expiry

Auth Store

TypeScript
// src/stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  role: 'user' | 'admin';
  is_superuser: boolean;
}

interface AuthState {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  setUser: (user: User | null) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      isAuthenticated: false,
      isLoading: true,
      setUser: (user) => set({ user, isAuthenticated: !!user, isLoading: false }),
      logout: () => set({ user: null, isAuthenticated: false }),
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({ user: state.user }),
    }
  )
);

Auth Hook

TypeScript
// src/hooks/use-auth.ts
import { useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';

export function useAuth() {
  const router = useRouter();
  const { user, isAuthenticated, isLoading, setUser, logout: storeLogout } = useAuthStore();

  // Check auth status on mount
  useEffect(() => {
    const checkAuth = async () => {
      try {
        const res = await fetch('/api/auth/me');
        if (res.ok) {
          const data = await res.json();
          setUser(data.user);
        } else {
          setUser(null);
        }
      } catch {
        setUser(null);
      }
    };
    checkAuth();
  }, [setUser]);

  const login = useCallback(async (email: string, password: string) => {
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });

    if (!res.ok) {
      throw new Error('Invalid credentials');
    }

    const data = await res.json();
    setUser(data.user);
    router.push('/dashboard');
  }, [router, setUser]);

  const logout = useCallback(async () => {
    await fetch('/api/auth/logout', { method: 'POST' });
    storeLogout();
    router.push('/login');
  }, [router, storeLogout]);

  return { user, isAuthenticated, isLoading, login, logout };
}

API Routes (BFF Pattern)

Backend-for-Frontend routes handle token storage:

TypeScript
// src/app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';

const API_URL = process.env.BACKEND_URL || 'http://localhost:8000';

export async function POST(request: NextRequest) {
  const body = await request.json();

  const res = await fetch(`${API_URL}/api/v1/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      username: body.email,
      password: body.password,
    }),
  });

  if (!res.ok) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  const data = await res.json();

  // Set HTTP-only cookies
  const cookieStore = await cookies();
  cookieStore.set('access_token', data.access_token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 30, // 30 minutes
  });
  cookieStore.set('refresh_token', data.refresh_token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 days
  });

  return NextResponse.json({ user: data.user });
}

Protected Routes

Use middleware to protect routes:

TypeScript
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const protectedPaths = ['/dashboard', '/chat', '/profile'];
const authPaths = ['/login', '/register'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const accessToken = request.cookies.get('access_token')?.value;

  // Redirect to login if accessing protected route without token
  if (protectedPaths.some((path) => pathname.startsWith(path))) {
    if (!accessToken) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  // Redirect to dashboard if accessing auth routes with token
  if (authPaths.some((path) => pathname.startsWith(path))) {
    if (accessToken) {
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

AI Chat

WebSocket Connection

TypeScript
// src/hooks/use-websocket.ts
import { useCallback, useEffect, useRef, useState } from 'react';

interface UseWebSocketOptions {
  onMessage?: (data: any) => void;
  onOpen?: () => void;
  onClose?: () => void;
  onError?: (error: Event) => void;
}

export function useWebSocket(url: string, options: UseWebSocketOptions = {}) {
  const [isConnected, setIsConnected] = useState(false);
  const wsRef = useRef<WebSocket | null>(null);

  const connect = useCallback(() => {
    const ws = new WebSocket(url);

    ws.onopen = () => {
      setIsConnected(true);
      options.onOpen?.();
    };

    ws.onclose = () => {
      setIsConnected(false);
      options.onClose?.();
    };

    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      options.onMessage?.(data);
    };

    ws.onerror = (error) => {
      options.onError?.(error);
    };

    wsRef.current = ws;
  }, [url, options]);

  const disconnect = useCallback(() => {
    wsRef.current?.close();
    wsRef.current = null;
  }, []);

  const send = useCallback((data: any) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(data));
    }
  }, []);

  useEffect(() => {
    return () => disconnect();
  }, [disconnect]);

  return { isConnected, connect, disconnect, send };
}

Chat Hook

TypeScript
// src/hooks/use-chat.ts
import { useCallback, useState } from 'react';
import { useChatStore } from '@/stores/chat-store';
import { useWebSocket } from './use-websocket';

const WS_URL = process.env.NEXT_PUBLIC_WS_URL || 'ws://localhost:8000/api/v1/agent/ws';

export function useChat() {
  const [isStreaming, setIsStreaming] = useState(false);
  const { messages, addMessage, updateLastMessage, clearMessages } = useChatStore();

  const handleMessage = useCallback((data: any) => {
    switch (data.type) {
      case 'start':
        setIsStreaming(true);
        addMessage({ role: 'assistant', content: '' });
        break;
      case 'token':
        updateLastMessage((prev) => prev + data.content);
        break;
      case 'tool_call':
        addMessage({
          role: 'tool',
          content: JSON.stringify(data.tool),
          tool_name: data.tool.name,
        });
        break;
      case 'end':
        setIsStreaming(false);
        break;
      case 'error':
        setIsStreaming(false);
        console.error('Chat error:', data.error);
        break;
    }
  }, [addMessage, updateLastMessage]);

  const { isConnected, connect, disconnect, send } = useWebSocket(WS_URL, {
    onMessage: handleMessage,
  });

  const sendMessage = useCallback((content: string) => {
    addMessage({ role: 'user', content });
    send({ type: 'message', content, history: messages });
  }, [addMessage, messages, send]);

  return {
    messages,
    isConnected,
    isStreaming,
    connect,
    disconnect,
    sendMessage,
    clearMessages,
  };
}

Chat Container

TSX
// src/components/chat/chat-container.tsx
'use client';

import { useEffect } from 'react';
import { useChat } from '@/hooks/use-chat';
import { ChatInput } from './chat-input';
import { MessageList } from './message-list';

export function ChatContainer() {
  const { messages, isConnected, isStreaming, connect, disconnect, sendMessage } = useChat();

  useEffect(() => {
    connect();
    return () => disconnect();
  }, [connect, disconnect]);

  return (
    <div className="flex h-full flex-col">
      <div className="flex-1 overflow-y-auto">
        <MessageList messages={messages} isStreaming={isStreaming} />
      </div>
      <ChatInput onSend={sendMessage} disabled={!isConnected || isStreaming} />
    </div>
  );
}

State Management

Zustand Stores

TypeScript
// src/stores/chat-store.ts
import { create } from 'zustand';
import { nanoid } from 'nanoid';

interface Message {
  id: string;
  role: 'user' | 'assistant' | 'tool';
  content: string;
  tool_name?: string;
  created_at: Date;
}

interface ChatState {
  messages: Message[];
  addMessage: (message: Omit<Message, 'id' | 'created_at'>) => void;
  updateLastMessage: (updater: (content: string) => string) => void;
  clearMessages: () => void;
}

export const useChatStore = create<ChatState>((set) => ({
  messages: [],

  addMessage: (message) =>
    set((state) => ({
      messages: [
        ...state.messages,
        { ...message, id: nanoid(), created_at: new Date() },
      ],
    })),

  updateLastMessage: (updater) =>
    set((state) => ({
      messages: state.messages.map((msg, idx) =>
        idx === state.messages.length - 1
          ? { ...msg, content: updater(msg.content) }
          : msg
      ),
    })),

  clearMessages: () => set({ messages: [] }),
}));

TanStack Query

For server state management:

TypeScript
// src/lib/api-client.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      retry: 1,
    },
  },
});

// Example hook
export function useItems() {
  return useQuery({
    queryKey: ['items'],
    queryFn: async () => {
      const res = await fetch('/api/items');
      if (!res.ok) throw new Error('Failed to fetch items');
      return res.json();
    },
  });
}

UI Components

Button Component

TSX
// src/components/ui/button.tsx
import { forwardRef } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
        secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
        link: 'text-primary underline-offset-4 hover:underline',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
Button.displayName = 'Button';

export { Button, buttonVariants };

Testing

Unit Tests (Vitest)

TypeScript
// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { cn } from './utils';

describe('cn utility', () => {
  it('merges class names', () => {
    expect(cn('foo', 'bar')).toBe('foo bar');
  });

  it('handles conditional classes', () => {
    expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz');
  });

  it('merges Tailwind classes correctly', () => {
    expect(cn('p-4', 'p-2')).toBe('p-2');
  });
});

E2E Tests (Playwright)

TypeScript
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('should login successfully', async ({ page }) => {
    await page.goto('/login');

    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('text=Dashboard')).toBeVisible();
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.goto('/login');

    await page.fill('input[name="email"]', 'wrong@example.com');
    await page.fill('input[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    await expect(page.locator('text=Invalid credentials')).toBeVisible();
  });
});

Running Tests

Bash
# Unit tests
bun test
bun test:run        # CI mode
bun test:coverage   # With coverage
bun test:ui         # UI mode

# E2E tests
bun test:e2e
bun test:e2e:ui     # UI mode
bun test:e2e:headed # With browser
bun test:e2e:debug  # Debug mode

Internationalization (i18n)

When enabled, uses next-intl:

TypeScript
// src/i18n.ts
import { getRequestConfig } from 'next-intl/server';

export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`../messages/${locale}.json`)).default,
}));
JSON
// messages/en.json
{
  "common": {
    "login": "Login",
    "logout": "Logout",
    "loading": "Loading..."
  },
  "auth": {
    "email": "Email",
    "password": "Password",
    "loginButton": "Sign in"
  }
}

Usage in components:

TSX
import { useTranslations } from 'next-intl';

export function LoginForm() {
  const t = useTranslations('auth');

  return (
    <form>
      <label>{t('email')}</label>
      <input name="email" />
      <button>{t('loginButton')}</button>
    </form>
  );
}

Dark Mode

Theme Store

TypeScript
// src/stores/theme-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

type Theme = 'light' | 'dark' | 'system';

interface ThemeState {
  theme: Theme;
  setTheme: (theme: Theme) => void;
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      theme: 'system',
      setTheme: (theme) => set({ theme }),
    }),
    { name: 'theme-storage' }
  )
);

Theme Provider

TSX
// src/components/theme/theme-provider.tsx
'use client';

import { useEffect } from 'react';
import { useThemeStore } from '@/stores/theme-store';

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const { theme } = useThemeStore();

  useEffect(() => {
    const root = window.document.documentElement;
    root.classList.remove('light', 'dark');

    if (theme === 'system') {
      const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light';
      root.classList.add(systemTheme);
    } else {
      root.classList.add(theme);
    }
  }, [theme]);

  return <>{children}</>;
}

Development Commands

Bash
# Start development server
bun dev

# Build for production
bun build

# Start production server
bun start

# Linting
bun lint
bun lint:fix

# Formatting
bun format
bun format:check

# Type checking
bun type-check

# Testing
bun test
bun test:e2e

Environment Variables

Bash
# .env.local

# Backend API URL (for server-side)
BACKEND_URL=http://localhost:8000

# Public WebSocket URL (for client-side)
NEXT_PUBLIC_WS_URL=ws://localhost:8000/api/v1/agent/ws

# OpenTelemetry (if Logfire enabled)
OTEL_EXPORTER_OTLP_ENDPOINT=https://logfire-api.pydantic.dev