Skip to main content

Tech Stack

  • Framework: Next.js 16 (App Router)
  • UI Library: Radix UI primitives
  • Styling: Tailwind CSS v4 + CSS variables
  • State Management: TanStack Query (React Query)
  • API Client: openapi-fetch (type-safe)
  • Forms: React Hook Form + Zod validation
  • Auth: Auth0 Next.js SDK

Project Structure

frontend/
├── app/                    # Next.js app router
│   ├── (dashboard)/       # Protected routes
│   ├── api/               # API routes (SSE proxy)
│   └── layout.tsx         # Root layout
├── components/
│   ├── ui/                # Radix UI primitives
│   ├── providers/         # Context providers
│   └── [feature].tsx      # Feature components
├── lib/
│   ├── api-client/        # Generated API client
│   └── utils.ts           # Utilities
├── hooks/                 # Custom React hooks
└── styles/
    └── globals.css        # Global styles

Component Library (Radix UI)

LatentGEO uses Radix UI for accessible, unstyled primitives.

Button Component

import { Button } from '@/components/ui/button';

Dialog Component

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';

function DeleteAuditDialog() {
  return (
    <Dialog>
      <DialogTrigger asChild>
        <Button variant="destructive">Delete</Button>
      </DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Are you sure?</DialogTitle>
          <DialogDescription>
            This action cannot be undone.
          </DialogDescription>
        </DialogHeader>
        <div className="flex gap-2">
          <Button variant="outline">Cancel</Button>
          <Button variant="destructive" onClick={handleDelete}>
            Delete
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}

Card Component

import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card';

function AuditCard({ audit }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>{audit.url}</CardTitle>
        <CardDescription>
          Status: {audit.status}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <p>Progress: {audit.progress}%</p>
      </CardContent>
    </Card>
  );
}

Available Components

Button

Dialog

Card

Tabs

Select

Input

Label

Checkbox

Badge

Table

Sheet

Textarea

All components are in components/ui/ and built on Radix UI primitives.

Theming and Styling

Tailwind CSS v4

LatentGEO uses Tailwind CSS with CSS variables for theming. Global Styles (styles/globals.css):
@import "tailwindcss";

:root {
  --background: 0 0% 100%;
  --foreground: 240 10% 3.9%;
  --primary: 240 5.9% 10%;
  --primary-foreground: 0 0% 98%;
  --border: 240 5.9% 90%;
  --ring: 240 5.9% 10%;
}

.dark {
  --background: 240 10% 3.9%;
  --foreground: 0 0% 98%;
  --primary: 0 0% 98%;
  --primary-foreground: 240 5.9% 10%;
  --border: 240 3.7% 15.9%;
  --ring: 240 4.9% 83.9%;
}

Theme Toggle

import { useTheme } from 'next-themes';
import { Button } from '@/components/ui/button';
import { MoonIcon, SunIcon } from 'lucide-react';

export function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <Button
      variant="ghost"
      size="icon"
      onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
    >
      <SunIcon className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
      <MoonIcon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
    </Button>
  );
}

Theme Provider

import { ThemeProvider } from 'next-themes';

export default function RootLayout({ children }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

Utility Function

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

// Usage
<div className={cn("base-class", isActive && "active-class")} />

API Client Integration

Type-Safe API Client

Generated from OpenAPI schema:
import createClient from 'openapi-fetch';
import type { paths } from './schema';

const apiClient = createClient<paths>({
  baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000',
});

export { apiClient };

Making API Calls

import { apiClient } from '@/lib/api-client';

const { data, error } = await apiClient.GET('/api/v1/audits/{audit_id}', {
  params: {
    path: { audit_id: 123 },
  },
});

if (error) {
  console.error('Failed to fetch audit:', error);
  return;
}

console.log('Audit:', data);

Auth Token Injection

import { getAccessToken } from '@auth0/nextjs-auth0';

const accessToken = await getAccessToken();

const { data } = await apiClient.GET('/api/v1/audits/', {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});

State Management (TanStack Query)

Query Provider

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export function QueryProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1 minute
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Using Queries

import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';

function AuditList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['audits'],
    queryFn: async () => {
      const { data, error } = await apiClient.GET('/api/v1/audits/');
      if (error) throw error;
      return data;
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      {data?.map((audit) => (
        <AuditCard key={audit.id} audit={audit} />
      ))}
    </div>
  );
}

Using Mutations

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';

function CreateAuditButton() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (url: string) => {
      const { data, error } = await apiClient.POST('/api/v1/audits/', {
        body: { url },
      });
      if (error) throw error;
      return data;
    },
    onSuccess: () => {
      // Invalidate and refetch audits list
      queryClient.invalidateQueries({ queryKey: ['audits'] });
    },
  });

  return (
    <Button
      onClick={() => mutation.mutate('https://example.com')}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? 'Creating...' : 'Create Audit'}
    </Button>
  );
}

Form Handling

React Hook Form + Zod

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

const auditSchema = z.object({
  url: z.string().url('Must be a valid URL'),
  competitors: z.array(z.string()).optional(),
});

type AuditFormData = z.infer<typeof auditSchema>;

function CreateAuditForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<AuditFormData>({
    resolver: zodResolver(auditSchema),
  });

  const onSubmit = (data: AuditFormData) => {
    console.log('Form data:', data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <Label htmlFor="url">Website URL</Label>
        <Input
          id="url"
          {...register('url')}
          placeholder="https://example.com"
        />
        {errors.url && (
          <p className="text-sm text-destructive">{errors.url.message}</p>
        )}
      </div>
      <Button type="submit">Create Audit</Button>
    </form>
  );
}

Real-Time Updates (SSE)

SSE Hook

import { useEffect, useState } from 'react';

export function useAuditSSE(auditId: number) {
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState<string>('pending');

  useEffect(() => {
    const eventSource = new EventSource(
      `/api/sse/audits/${auditId}/progress`
    );

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setProgress(data.progress);
      setStatus(data.status);
    };

    eventSource.onerror = () => {
      eventSource.close();
    };

    return () => eventSource.close();
  }, [auditId]);

  return { progress, status };
}

Using SSE Hook

import { useAuditSSE } from '@/hooks/useAuditSSE';
import { Progress } from '@/components/ui/progress';

function AuditProgress({ auditId }: { auditId: number }) {
  const { progress, status } = useAuditSSE(auditId);

  return (
    <div>
      <p>Status: {status}</p>
      <Progress value={progress} />
      <p>{progress}% complete</p>
    </div>
  );
}

Best Practices

Next.js App Router defaults to Server Components. Only use 'use client' when needed (hooks, state, events).
Keep components close to where they’re used. Only extract to components/ when reused.
Use TypeScript for all components. Let the API client generate types from OpenAPI.
Use Next.js <Image> component with proper width, height, and alt attributes.
Use dynamic imports for charts and heavy libraries:
const Chart = dynamic(() => import('@/components/Chart'), {
  ssr: false,
  loading: () => <Skeleton />,
});

Next Steps

Backend Services

Learn the APIs these components consume

Testing

Test your components with Vitest

Contributing

Contribute UI components

Local Setup

Set up your development environment

Build docs developers (and LLMs) love