Frontend Setup & Configuration Guide
Comprehensive guide to frontend setup, authentication, routing, state management, styling, and API integration
Table of Contents
- Authentication
- Routing & Navigation
- State Management
- API Integration with tRPC
- 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