Frontend Architecture

The Eventuall frontend is a Next.js 15 application using the App Router, React 19, and TypeScript. It runs on Cloudflare Pages via OpenNext.js, which adapts Next.js's Node.js runtime to Cloudflare's edge environment.

How the App Router Works

Next.js 15's App Router uses the filesystem to define routes. Every folder inside apps/webapp/src/app/ becomes a URL path, and special filenames (page.tsx, layout.tsx, loading.tsx, error.tsx) control what renders at each level.

When a user requests a page, Next.js renders Server Components on the server and sends HTML to the browser. Client Components hydrate on the browser to add interactivity. This means most of the page loads instantly as static HTML, then becomes interactive once JavaScript loads.

apps/webapp/src/app/
├── page.tsx                          # / (home)
├── layout.tsx                        # Root layout (wraps everything)
├── login/
│   └── page.tsx                      # /login
├── event/
│   └── [eventId]/
│       ├── layout.tsx                # Shared layout for all event pages
│       ├── page.tsx                  # /event/:eventId
│       └── backstage/
│           └── page.tsx              # /event/:eventId/backstage
└── developer/
    └── [accountId]/
        ├── layout.tsx                # Developer dashboard layout
        └── events/
            └── page.tsx              # /developer/:accountId/events

Route Groups

The codebase uses route groups (folders in parentheses) to share layouts without affecting URLs. For example, (auth) groups authentication pages under a shared layout, while (dashboard) groups admin pages under a different one. The parentheses mean these folders don't appear in the URL.

Dynamic Routes

Square brackets denote dynamic segments. [eventId] captures a variable from the URL and passes it to the page component as a parameter. In Next.js 15, these params are Promises that you await:

export default async function EventPage(props: {
  params: Promise<{ eventId: string }>;
}) {
  const { eventId } = await props.params;
  const event = await api.event.getEventById({ eventId });
  // ...
}

Pitfall: In Next.js 15, params is a Promise. If you destructure it without await, you'll get a runtime error. This is a change from Next.js 14 where params were synchronous.

Server Components vs. Client Components

By default, every component in the App Router is a Server Component. Server Components run on the server, can directly access the database, and never send their code to the browser. This keeps the JavaScript bundle small.

Client Components are marked with "use client" at the top of the file. They run in the browser and can use React hooks (useState, useEffect), event handlers, and browser APIs. The Eventuall codebase follows a strict pattern: pages and data-fetching wrappers are Server Components, while interactive UI elements (buttons, forms, video players) are Client Components.

The Boundary Rule

A Server Component can render a Client Component, but a Client Component cannot import a Server Component directly. If you need server-rendered content inside a Client Component, pass it as children props:

// Server Component
export default async function EventPage() {
  const event = await api.event.getEventById({ eventId });
  return (
    <InteractiveWrapper>        {/* Client Component */}
      <EventDetails event={event} /> {/* Server Component passed as children */}
    </InteractiveWrapper>
  );
}

Architect's Note: The "islands" architecture pattern is central to performance. Each "use client" boundary adds to the JavaScript bundle. In Eventuall, we keep Client Components small and leaf-level: a toggle button, a form input, a video player. The outer page structure stays as Server Components to minimize client-side JavaScript.

Provider Setup

The root layout wraps the entire application in several providers that both Server and Client Components rely on:

  • SessionProvider (NextAuth) — makes the user's session available to Client Components
  • TRPCProvider — sets up the tRPC client with React Query for data fetching in Client Components
  • ThemeProvider — manages light/dark mode
  • Toaster (Sonner) — provides toast notifications

Server Components don't need these providers. They access the database and session directly through server-side imports.

Styling with Tailwind CSS and shadcn/ui

All styling uses Tailwind CSS utility classes. The design system is built on shadcn/ui, which provides accessible, customizable components built on Radix UI primitives.

The component library lives in apps/webapp/src/components/ui/. These aren't installed from npm — they're copied into the project and customized. The Button component, for example, has two variant systems: standard shadcn variants (default, destructive, outline) and custom Eventuall variants (blue, red, green, slideup, icon). A helper function isEventuallVariant() detects which system to use based on the variant name.

Responsive Design

The layout is mobile-first. Tailwind's responsive prefixes (sm:, md:, lg:) control how components adapt:

  • Mobile: Single column, collapsed navigation
  • Tablet (md:): Two-column layouts, expanded sidebar
  • Desktop (lg:): Full three-panel layouts for event backstage views

Architect's Note: The event backstage view is the most complex layout. It shows a video grid, participant list, chat, and queue management simultaneously. This uses CSS Grid with named areas, not nested flexbox, to keep the layout predictable as panels resize.

OpenNext.js and Cloudflare Pages

Standard Next.js expects a Node.js server. Since Eventuall runs on Cloudflare, the project uses OpenNext.js (@opennextjs/cloudflare) to adapt the Next.js output to Cloudflare Pages Functions.

This has practical implications:

  • No fs module — you can't read files at runtime. All data comes from D1, R2, or KV.
  • No long-running processes — each request is a short-lived worker invocation.
  • Environment access — instead of process.env, you access bindings through getCloudflareContext().
  • D1 database connections — must be obtained fresh per request (no connection pooling, no React.cache() on the DB client).

Pitfall: Using React.cache() to wrap the database client will cause stale connections on Cloudflare. The codebase explicitly avoids this — see apps/webapp/src/server/db/index.ts where the comment explains why.

Key Frontend Directories

Directory Purpose
src/app/ Pages, layouts, and routing
src/components/ui/ Base shadcn/ui components
src/components/livekit/ LiveKit video/audio components
src/components/chat/ Twilio chat components
src/hooks/ Custom React hooks
src/trpc/ tRPC client and server setup
src/server/ Server-side code (auth, actions, API)
src/lib/ Shared utilities