Next.js 16 frontend for BrandBlitz. Server-rendered landing page and leaderboard, client-side game flow, and brand management dashboard. Styled with Tailwind CSS v4 using a CSS-first configuration.
- Overview
- Directory Structure
- Getting Started
- Environment Variables
- Routing & Pages
- Route Groups
- Components
- Hooks
- Lib
- Tailwind v4 CSS Variables
- Authentication
- Game State Machine
- File Upload Flow
- Building & Running
- Docker
The web app is a Next.js 16 App Router project with output: "standalone" for Docker deployment. It uses:
- next-auth v4 for Google OAuth — session is stored in a cookie, API JWT is stored in the next-auth session object
- Tailwind CSS v4 CSS-first config — no
tailwind.config.js, all tokens defined as CSS variables inside@theme inline {} - shadcn-style UI components built with Radix UI primitives and
class-variance-authority - axios for all API calls via a typed client factory in
src/lib/api.ts
The landing page and leaderboard are async server components (SSR). The game pages and dashboard are client components because they depend on useSession(), real-time timers, and user interactions.
apps/web/
├── Dockerfile
├── .dockerignore
├── next.config.ts # output: standalone, image domains, server actions
├── postcss.config.mjs # @tailwindcss/postcss plugin
├── tsconfig.json
└── src/
├── app/
│ ├── layout.tsx # Root layout: SessionProvider, Inter font
│ ├── globals.css # Tailwind v4 @import + @theme inline CSS vars
│ ├── page.tsx # Landing page (async server component)
│ ├── favicon.ico
│ ├── api/
│ │ └── auth/[...nextauth]/route.ts # next-auth App Router handler
│ ├── (auth)/ # Centred layout, no header/footer
│ │ ├── layout.tsx
│ │ └── login/page.tsx # Google sign-in
│ ├── (game)/ # Full-screen game layout
│ │ ├── layout.tsx
│ │ └── challenge/[id]/
│ │ ├── page.tsx # Warmup → challenge → result state machine
│ │ └── loading.tsx # Suspense fallback skeleton
│ ├── (brand)/ # Header + footer layout
│ │ ├── layout.tsx
│ │ ├── dashboard/page.tsx # Brand owner dashboard
│ │ └── brand/
│ │ ├── new/page.tsx # Create brand kit form
│ │ └── [id]/page.tsx # Brand analytics + leaderboard
│ ├── leaderboard/
│ │ └── page.tsx # Global leaderboard (SSR)
│ └── profile/[username]/
│ └── page.tsx # Public user profile (SSR)
├── components/
│ ├── ui/ # Primitive UI components (shadcn-style)
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── badge.tsx
│ │ ├── input.tsx
│ │ ├── label.tsx
│ │ ├── progress.tsx
│ │ └── dialog.tsx
│ ├── game/ # Game-specific components
│ │ ├── constants.ts
│ │ ├── warmup-phase.tsx
│ │ ├── challenge-round.tsx
│ │ ├── countdown-timer.tsx
│ │ └── result-screen.tsx
│ ├── brand/ # Brand management components
│ │ ├── brand-kit-form.tsx
│ │ └── upload-field.tsx
│ └── layout/
│ ├── header.tsx
│ └── footer.tsx
├── hooks/
│ ├── use-challenge.ts # Fetch challenge + questions
│ └── use-countdown.ts # Countdown timer with callbacks
├── lib/
│ ├── api.ts # Typed axios client factory + exported types
│ ├── auth.ts # next-auth config (Google, JWT callbacks)
│ └── utils.ts # cn(), formatUsdc(), formatScore(), sleep()
└── types/
└── index.ts # Shared TypeScript interfaces
# From the monorepo root
pnpm install
# Copy environment
cp ../../.env.example .env.local
# Start infrastructure
docker compose up postgres redis minio minio-setup
# Run the API (needed for data fetching)
pnpm --filter @brandblitz/api dev
# Run the web app
pnpm --filter @brandblitz/web devOpen http://localhost:3000.
Set in .env.local for local development. In Docker, passed via docker compose environment config.
| Variable | Required | Description |
|---|---|---|
NEXTAUTH_SECRET |
Yes | Encrypts next-auth session cookie |
NEXTAUTH_URL |
Yes | Full URL of the web app (e.g. http://localhost:3000) |
GOOGLE_CLIENT_ID |
Yes | Google OAuth client ID |
GOOGLE_CLIENT_SECRET |
Yes | Google OAuth client secret |
NEXT_PUBLIC_API_URL |
Yes | API base URL visible to the browser (e.g. http://localhost:3001/api) |
NEXT_PUBLIC_ variables are embedded at build time — they must be available during next build.
Server component. Fetches active challenges from the Express API on the server and renders:
- Hero section with CTA buttons
- Grid of active challenge cards with brand colours and logos
- "How It Works" 3-step explainer
Falls back gracefully if the API is unavailable (returns empty array).
Client component. Renders a centred card with a Google sign-in button. Uses signIn("google", { callbackUrl }) from next-auth. Reads callbackUrl from query params so users are redirected back after auth.
Client component. Runs the full game state machine (see Game State Machine). Redirects to /login if the user is not authenticated.
Client component. Requires authentication. Loads the authenticated user's brand kits and their associated challenges. Links to brand analytics and challenge creation.
Client component. Requires authentication. Renders the BrandKitForm component.
Client component. Shows brand details, active challenge stats (pool size, participant count, status), and the real-time leaderboard for the latest challenge.
Server component. Fetches the global top-100 players from the API and renders a full-page ranked table with medals, league badges, scores, and total USDC earned.
Server component. Fetches a public user profile and renders their stats (challenges played, best score, USDC earned) and recent challenge history. Returns notFound() for unknown usernames.
Next.js route groups (name) apply shared layouts without affecting URLs:
| Group | Layout | Used for |
|---|---|---|
(auth) |
Vertically centred, no header/footer | Login page |
(game) |
Full-screen, no header/footer | Immersive challenge experience |
(brand) |
Header + footer | Dashboard, brand management |
Pages outside groups (landing, leaderboard, profile) render directly inside the root layout.
All components are in src/components/ui/ and follow the shadcn pattern: Radix UI primitives wrapped with class-variance-authority variants and CSS variable tokens.
Variants: default, destructive, outline, secondary, ghost, link
Sizes: sm, default, lg, icon
<Button variant="secondary" size="lg">Play Now</Button>Compound component: Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter
Variants: default, secondary, outline, destructive, gold, silver, bronze
<Badge variant="gold">Gold League</Badge>Styled <input> with focus ring using var(--primary).
Styled <label> that disables properly when paired with a disabled input.
Radix Progress.Root with a smooth animated fill bar. Used in the countdown timer.
<Progress value={75} className="h-2" />Radix Dialog with overlay, animated content, header, footer, title, and description subcomponents.
export const WARMUP_MIN_SECONDS = 20; // Server-enforced minimum warmup
export const ROUND_SECONDS = 15; // Time per question
export const TOTAL_ROUNDS = 3;Full-screen component that displays brand content (logo, tagline, story, product images) during the warmup. The "I'm Ready" button is disabled until WARMUP_MIN_SECONDS have elapsed. Calls onComplete(challengeToken) after the API confirms warmup completion.
Props: challenge: Challenge, onComplete: (token: string) => void
Displays a single MCQ question with 4 option buttons and a countdown timer. Records reactionTimeMs from when the question was shown. Calls onAnswer(option, reactionTimeMs) immediately on selection — no confirm step.
Props: question: ChallengeQuestion, round: 1|2|3, onAnswer, brandLogoUrl?
Visual countdown with a Progress bar that drains over the allotted time. Turns red (via text-red-500) when ≤ 5 seconds remain. Calls onExpire when time runs out.
Props: seconds: number, onExpire: () => void
Post-game screen showing total score, optional rank, estimated USDC earnings, a native share button (falls back to clipboard copy), a leaderboard link, and a "Play Another" CTA.
Props: totalScore: number, rank?: number, estimatedUsdc?: string, challengeId: string
Drag-and-drop / click-to-upload field that:
- Calls
POST /upload/presignto get a presigned S3 URL PUTs the file directly to MinIO/S3 (never through Express)- Calls
POST /upload/verifyto confirm the upload - Shows a thumbnail on success; calls
onUploaded(key, publicUrl)
Props: label, accept, uploadType, apiToken, onUploaded
Multi-section form combining brand info, asset uploads, and challenge settings. On submit:
POST /brands— creates the brand kitPOST /brands/:id/challenges— creates the challenge- Redirects to
/brand/:idwith deposit instructions in the query string
Sticky, blurred header with the BrandBlitz logo, navigation links, and auth state. Shows the user's Google avatar and a sign-out button when authenticated. Uses useSession() — rendered client-side.
Simple footer with copyright, Stellar attribution, and key navigation links.
const { timeLeft, running, start, pause, reset } = useCountdown({
seconds: 15,
onExpire: () => handleTimeout(),
autoStart: true,
});Uses setInterval with a stable onExpire ref so the callback can be updated without restarting the timer.
const { challenge, questions, loading, error } = useChallenge(id, apiToken);Fetches GET /challenges/:id and returns typed results. Used in pages that need challenge data outside the main game state machine.
// Unauthenticated (SSR / public routes)
const res = await api.get("/challenges?limit=6");
// Authenticated (client components)
const api = createApiClient(session.apiToken);
const res = await api.post(`/sessions/${id}/answer/1`, { ... });api (default export) is a bare axios instance pointing to NEXT_PUBLIC_API_URL.
createApiClient(token?) returns an instance with Authorization: Bearer <token> pre-set.
Exported types: Challenge, ChallengeQuestion, LeaderboardEntry
next-auth v4 config with:
GoogleProviderfor OAuthsignIncallback: callsPOST /auth/google/callbackon the Express API to get a JWT, stores it in the tokenjwtcallback: forwardsapiTokenthrough the JWTsessioncallback: exposesapiTokenon the session object so client components can read it viauseSession()
cn(...inputs: ClassValue[]) // Merges Tailwind classes (clsx + tailwind-merge)
formatUsdc(amount: string | number) // "100.0000000" → "100.00"
formatScore(score: number) // 420 → "420"
sleep(ms: number): Promise<void>Tailwind v4 uses a CSS-first configuration. There is no tailwind.config.js. All theme tokens are declared in src/app/globals.css inside the @theme inline {} block:
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
/* ... */
}
:root {
--background: #ffffff;
--foreground: #0a0a0a;
--primary: #6366f1;
--primary-foreground: #ffffff;
/* ... */
}Components reference tokens as bg-[var(--primary)] or text-[var(--muted-foreground)].
Brand-specific colours are overridden at runtime by setting --brand-primary and --brand-secondary on the challenge wrapper element.
- User clicks "Continue with Google" →
signIn("google") - Google redirects back to
/api/auth/callback/google(handled by next-auth) - next-auth's
signIncallback POSTs toNEXTAUTH_URL/api/auth→ ExpressPOST /auth/google/callback - Express creates/updates the user record, issues a signed JWT
- JWT is stored in the next-auth session as
session.apiToken - All client-side API calls read
session.apiTokenviauseSession()and pass it asAuthorization: Bearer
The next-auth session cookie is httpOnly, sameSite: lax, encrypted with NEXTAUTH_SECRET.
The challenge page (/challenge/:id) manages four phases via a useState:
loading → warmup → challenge → result
| Phase | Trigger | What happens |
|---|---|---|
loading |
Page mount | Fetches challenge + questions; calls warmup-start |
warmup |
warmup-start success |
Renders WarmupPhase; button locked 20s |
challenge |
User clicks "I'm Ready" → warmup-complete |
3 sequential ChallengeRound renders |
result |
Round 3 answer submitted | Renders ResultScreen with total score |
Answers are submitted one at a time via POST /sessions/:id/answer/:round. The server validates, scores, and returns the round score. The client accumulates scores in state for display on the result screen.
Direct-to-S3 upload (files never transit the Express server):
Browser Express API MinIO / S3
│ │ │
│ POST /upload/presign │ │
│ ─────────────────────────►│ │
│ { presignedUrl, key } │ │
│◄─────────────────────────│ │
│ │ │
│ PUT presignedUrl (file) │ │
│ ─────────────────────────────────────────────────►│
│ 200 OK │ │
│◄─────────────────────────────────────────────────│
│ │ │
│ POST /upload/verify │ │
│ ─────────────────────────►│ HeadObject(key) │
│ { verified: true } │◄──────────────────────│
│◄─────────────────────────│ │
The UploadField component handles all three steps transparently.
Unit tests use Vitest and React Testing Library.
pnpm --filter @brandblitz/web testE2E tests use Playwright. They run against a local build of the application and require the API and database to be running.
Ensure your .env.local is configured and infrastructure is up:
docker compose up -d postgres redis minio minio-setupRun the seeding script to create a test brand and challenge:
# From root
pnpm tsx scripts/seed-e2e-challenge.ts# From apps/web
pnpm exec playwright test
# Or with UI mode
pnpm exec playwright test --uiE2E tests are configured in playwright.config.ts and located in tests/e2e/. They use a programmatic authentication fixture to skip the Google login UI.
# Development
pnpm --filter @brandblitz/web dev # http://localhost:3000
# Type check
pnpm --filter @brandblitz/web type-check
# Production build
pnpm --filter @brandblitz/web build
# Start production server
pnpm --filter @brandblitz/web startThe Dockerfile is a 4-stage multi-stage build:
| Stage | Purpose |
|---|---|
deps |
Install all monorepo dependencies (cached) |
builder |
next build with build-time env args |
runner |
Minimal Alpine image; non-root nextjs user; output: standalone |
The standalone output copies only the files needed to run the server, keeping the final image small.
Build from the monorepo root (required — the Dockerfile copies shared packages):
docker build -f apps/web/Dockerfile \
--build-arg NEXT_PUBLIC_API_URL=https://yourapi.com/api \
--build-arg NEXTAUTH_URL=https://yourapp.com \
-t brandblitz-web .