Docs /frontend/setup

Frontend Setup & Configuration Guide

Comprehensive guide to frontend setup, authentication, routing, state management, styling, and API integration

Table of Contents

  1. Authentication
  2. Routing & Navigation
  3. State Management
  4. API Integration with tRPC
  5. Styling with Tailwind CSS

Authentication

Better Auth integration and route protection patterns for Izri.

Overview

The web app uses Better Auth for session management with server-first validation in React Router loaders. Authentication status is provided at the root, and organization-scoped routes are protected by an auth layout and org validation.

Key points:

  • Server-side session via Better Auth (auth.api.getSession)
  • Root loader returns user to all routes
  • Auth pages redirect if already authenticated
  • Organization routes require authentication and valid org membership

Core Files

  • apps/web/app/lib/auth-server.ts — getAuthenticatedUser(request)
  • apps/web/app/lib/auth-loader.ts — requireAuth(request), redirectIfAuthenticated(request)
  • apps/web/app/lib/organization-loader.ts — requireOrganization(), requireOrganizationAdmin()
  • apps/web/app/root.tsx — root loader publishes user
  • apps/web/app/routes/_auth.tsx — auth layout (redirect if authenticated)
  • apps/web/app/routes/_organizations.tsx — protected org layout

Server-Side Session

File: apps/web/app/lib/auth-server.ts

import { auth } from '@izri/auth'
import type { AuthenticatedUser } from '@izri/shared'

export async function getAuthenticatedUser(request: Request): Promise<AuthenticatedUser | null> {
  try {
    const sessionData = await auth.api.getSession({ headers: request.headers })
    if (!sessionData?.user) return null
    return {
      id: sessionData.user.id,
      email: sessionData.user.email,
      name: sessionData.user.name,
      image: sessionData.user.image,
    }
  } catch {
    return null
  }
}

Loader Helpers

File: apps/web/app/lib/auth-loader.ts

import { redirect } from 'react-router'
import { getAuthenticatedUser } from './auth-server'

export async function requireAuth(request: Request) {
  const user = await getAuthenticatedUser(request)
  if (!user) throw redirect('/auth/login')
  return user
}

export async function redirectIfAuthenticated(request: Request) {
  const user = await getAuthenticatedUser(request)
  if (user) throw redirect('/organizations')
  return null
}

Root Loader

File: apps/web/app/root.tsx

export async function loader({ request }: Route.LoaderArgs) {
  const user = await getAuthenticatedUser(request)
  return { user }
}

All routes can read the root user via useRouteLoaderData('root') or through props provided by layouts.

Organization Access Checks

File: apps/web/app/lib/organization-loader.ts

import { redirect } from 'react-router'
import { createServerTRPC } from './trpc-server'

export async function requireOrganization(request: Request, slug: string, user: AuthenticatedUser) {
  try {
    const trpc = await createServerTRPC(request)
    const result = await trpc.organizations.getBySlug({ slug, userEmail: user.email })
    return result.organization
  } catch {
    throw redirect('/organizations')
  }
}

export function requireOrganizationAdmin(org: { slug: string; role: string }) {
  if (!['OWNER', 'ADMIN'].includes(org.role)) {
    throw redirect(`/organizations/${org.slug}`)
  }
}

Notes

  • tRPC client uses cookie-based auth; requests include credentials: 'include'
  • Prefer server-side checks in loaders for access control
  • Avoid client-only guards for protected data

Routing & Navigation

Complete guide to routing patterns and setup in Izri.

React Router v7 Overview

Izri uses React Router v7 for routing, SSR data loading, and layouts.

Why React Router v7?

  • File-based routing with typed loaders
  • SSR by default with optional SPA mode
  • Data loaders and actions at the route level
  • Automatic code splitting
  • Vite-powered dev experience

Project Structure

apps/web/
├── app/
│   ├── root.tsx               # Root layout
│   ├── routes.ts              # Route config (authoritative)
│   ├── routes/                # Route files
│   │   ├── home.tsx           # Homepage (/)
│   │   ├── _auth.tsx          # Auth layout
│   │   ├── _organizations.tsx # Organizations layout (auth-protected)
│   │   ├── auth/
│   │   │   ├── login.tsx      # /auth/login
│   │   │   ├── register.tsx   # /auth/register
│   │   │   └── logout.tsx     # /auth/logout
│   │   └── organizations/
│   │       ├── index.tsx                      # /organizations
│   │       ├── new.tsx                        # /organizations/new
│   │       ├── $slug.tsx                      # layout for slugged routes
│   │       └── $slug/
│   │           ├── index.tsx                  # /organizations/:slug
│   │           ├── dashboard.tsx              # /organizations/:slug/dashboard
│   │           ├── stats.tsx                  # /organizations/:slug/stats
│   │           ├── projects/
│   │           │   ├── index.tsx              # /organizations/:slug/projects
│   │           │   ├── new.tsx                # /organizations/:slug/projects/new
│   │           │   └── $id.tsx                # /organizations/:slug/projects/:id
│   │           ├── settings.tsx               # /organizations/:slug/settings
│   │           ├── members.tsx                # /organizations/:slug/members
│   │           └── invite.tsx                 # /organizations/:slug/invite
│   └── app.css                # Global styles
├── react-router.config.ts     # React Router config
├── vite.config.ts             # Vite config
└── package.json

Configuration

File: apps/web/react-router.config.ts

import type { Config } from '@react-router/dev/config'

export default {
  ssr: true,
} satisfies Config

File: apps/web/vite.config.ts

import { reactRouter } from '@react-router/dev/vite'
import { defineConfig } from 'vite'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [reactRouter(), tsconfigPaths()],
  server: { port: 5173 },
})

Route Configuration

File: apps/web/app/routes.ts

import { index, layout, type RouteConfig, route } from '@react-router/dev/routes'

export default [
  index('routes/home.tsx'),
  route('auth/logout', 'routes/auth/logout.tsx'),

  layout('routes/_auth.tsx', [
    route('auth/login', 'routes/auth/login.tsx'),
    route('auth/register', 'routes/auth/register.tsx'),
  ]),

  layout('routes/_organizations.tsx', [
    route('organizations', 'routes/organizations/index.tsx'),
    route('organizations/new', 'routes/organizations/new.tsx'),

    layout('routes/organizations/$slug.tsx', [
      route('organizations/:slug', 'routes/organizations/$slug/index.tsx'),
      route('organizations/:slug/dashboard', 'routes/organizations/$slug/dashboard.tsx'),
      route('organizations/:slug/stats', 'routes/organizations/$slug/stats.tsx'),
      route('organizations/:slug/projects', 'routes/organizations/$slug/projects/index.tsx'),
      route('organizations/:slug/projects/new', 'routes/organizations/$slug/projects/new.tsx'),
      route('organizations/:slug/projects/:id', 'routes/organizations/$slug/projects/$id.tsx'),
      route('organizations/:slug/settings', 'routes/organizations/$slug/settings.tsx'),
      route('organizations/:slug/members', 'routes/organizations/$slug/members.tsx'),
      route('organizations/:slug/invite', 'routes/organizations/$slug/invite.tsx'),
    ]),
  ]),
] satisfies RouteConfig

Current Routes

/                               → routes/home.tsx (public)
/auth/login                     → routes/auth/login.tsx (public)
/auth/register                  → routes/auth/register.tsx (public)
/auth/logout                    → routes/auth/logout.tsx (protected)
/organizations                  → routes/organizations/index.tsx (protected)
/organizations/new              → routes/organizations/new.tsx (protected)
/organizations/:slug            → routes/organizations/$slug/index.tsx (protected)
/organizations/:slug/dashboard  → routes/organizations/$slug/dashboard.tsx (protected)
/organizations/:slug/projects   → routes/organizations/$slug/projects/index.tsx (protected)
/organizations/:slug/settings   → routes/organizations/$slug/settings.tsx (protected)
/organizations/:slug/members    → routes/organizations/$slug/members.tsx (protected)
/organizations/:slug/invite     → routes/organizations/$slug/invite.tsx (protected)

Data Loading Patterns

Auth Protection:

import { Outlet } from 'react-router'
import { requireAuth } from '@/lib/auth-loader'

export async function loader({ request }: Route.LoaderArgs) {
  const user = await requireAuth(request)
  return { user }
}

export default function OrganizationsLayout() {
  return <Outlet />
}

Organization Validation:

import { requireOrganization } from '@/lib/organization-loader'
import { requireAuth } from '@/lib/auth-loader'

export async function loader({ request, params }: Route.LoaderArgs) {
  const user = await requireAuth(request)
  const organization = await requireOrganization(request, params.slug!, user)
  return { user, organization }
}

State Management

Managing state and data in Izri frontend.

State Categories

1. Server State (tRPC + TanStack Query)

  • Data from the backend (projects, users, organizations)
  • Tools: tRPC, TanStack Query

2. Route State (React Router)

  • URL parameters, route loaders
  • Tools: React Router loaders, useParams, useSearchParams

3. Component State (React)

  • UI state (modals, forms, toggles)
  • Tools: useState, useReducer

4. Global State (Context)

  • App-wide state (auth, theme, current org)
  • Tools: React Context

Preferred Pattern

Use tRPC + React Router loaders for most data. Component state for temporary UI state. Context only for truly global app state.


API Integration with tRPC

Using tRPC with TanStack Query for type-safe API calls in the web app.

Overview

The web app uses tRPC for end-to-end type safety and TanStack Query for caching, loading states, and mutations.

Key points:

  • Single HTTP endpoint at /trpc
  • Cookie-based auth; fetch uses credentials: 'include'
  • React-side context via createTRPCContext and TRPCProvider wrapper

Files

  • app/lib/trpc.ts — creates the TRPC React context utilities
  • app/providers/trpc-provider.tsx — configures the tRPC client and QueryClient
  • app/root.tsx — mounts TRPCProvider at the root

Setup

File: app/lib/trpc.ts

import type { AppRouter } from '@izri/trpc/routers'
import { createTRPCContext } from '@trpc/tanstack-react-query'

export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext<AppRouter>()

File: app/providers/trpc-provider.tsx

import type { AppRouter } from '@izri/trpc/routers'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import { type ReactNode, useState } from 'react'

import { TRPCProvider as BaseTRPCProvider } from '@/lib/trpc'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { staleTime: 60_000 },
    },
  })
}

let browserQueryClient: QueryClient | undefined

function getQueryClient() {
  if (typeof window === 'undefined') return makeQueryClient()
  if (!browserQueryClient) browserQueryClient = makeQueryClient()
  return browserQueryClient
}

export function TRPCProvider({ children }: { children: ReactNode }) {
  const queryClient = getQueryClient()
  const [trpcClient] = useState(() =>
    createTRPCClient<AppRouter>({
      links: [
        httpBatchLink({
          url: '/trpc',
          fetch(url, options) {
            return fetch(url, { ...options, credentials: 'include' })
          },
        }),
      ],
    }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <BaseTRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
        {children}
      </BaseTRPCProvider>
    </QueryClientProvider>
  )
}

Queries

Basic list query:

import { useTRPC } from '@/lib/trpc'
import { useQuery } from '@tanstack/react-query'

export function OrganizationsList({ userEmail }: { userEmail: string }) {
  const trpc = useTRPC()
  const { data, isPending, error } = useQuery({
    ...trpc.organizations.list.queryOptions({ userEmail }),
    enabled: !!userEmail,
  })

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

  return (
    <ul>
      {data?.organizations.map((o) => (
        <li key={o.id}>{o.name}</li>
      ))}
    </ul>
  )
}

Mutations

Create with cache invalidation:

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTRPC } from '@/lib/trpc'

export function CreateOrganizationButton() {
  const trpc = useTRPC()
  const qc = useQueryClient()

  const createOrg = useMutation({
    ...trpc.organizations.create.mutationOptions(),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: trpc.organizations.list.queryKey() })
    },
  })

  return (
    <button onClick={() => createOrg.mutate({ name: 'New Org' })}>
      Create Organization
    </button>
  )
}

Styling with Tailwind CSS

Tailwind CSS v4 styling practices in Izri.

Setup

File: apps/web/tailwind.config.ts

import type { Config } from 'tailwindcss'

export default {
  content: ['./app/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#eff6ff',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        },
      },
    },
  },
} satisfies Config

File: apps/web/app/app.css

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 222.2 84% 4.9%;
  }

  * {
    @apply border-border;
  }

  body {
    @apply bg-background text-foreground;
  }
}

Utility Classes

Layout:

<div className="container mx-auto px-4">
  <div className="flex items-center justify-between">
    Content
  </div>
</div>

Component Styling with CVA:

import { cva, type VariantProps } from 'class-variance-authority'

const buttonVariants = cva(
  'rounded-lg px-4 py-2 font-medium transition-colors',
  {
    variants: {
      variant: {
        primary: 'bg-blue-600 text-white hover:bg-blue-700',
        secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
        danger: 'bg-red-600 text-white hover:bg-red-700',
      },
      size: {
        sm: 'text-sm px-3 py-1.5',
        md: 'text-base px-4 py-2',
        lg: 'text-lg px-6 py-3',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
)

interface ButtonProps extends VariantProps<typeof buttonVariants> {
  className?: string
}

export function Button({ variant, size, className, ...props }: ButtonProps) {
  return (
    <button className={buttonVariants({ variant, size, className })} {...props} />
  )
}

Responsive Design

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  <div className="p-4">Item 1</div>
  <div className="p-4">Item 2</div>
  <div className="p-4">Item 3</div>
</div>

Breakpoints: sm (640px), md (768px), lg (1024px), xl (1280px), 2xl (1536px)

Dark Mode

export default {
  darkMode: 'class',
}

Related: Components & Forms | Testing | README

Reading this with an agent? /docs/frontend/setup.md serves the raw markdown.

All docs