# Technical Findings: Next.js App Router Data Staleness in Production ## Issue Summary **Problem**: Freshly generated database data (TournamentRound and BracketMatchup records) created via POST `/api/tournaments/[id]/schedule` fails to appear immediately after a browser refresh in production builds, despite the server component having `revalidate = 0` and `dynamic = "force-dynamic"`. **Context**: The test suite `schedule-tab.test.ts` shows that data is created successfully in the database but the page refresh doesn't immediately display the new data in production builds. --- ## Root Cause Analysis ### 1. Next.js Data Cache Behavior **Finding**: Next.js App Router caches `fetch` responses by default in production. While `revalidate = 0` and `dynamic = "force-dynamic"` disable full-route caching, they do not automatically disable the Data Cache for individual `fetch` requests. **Evidence from codebase**: - `src/app/admin/tournaments/[id]/schedule/page.tsx` sets: ```typescript export const dynamic = "force-dynamic" export const revalidate = 0 ``` - However, the page uses Prisma directly, not `fetch`. The page query `prisma.event.findUnique` is not subject to Next.js fetch caching, but the **browser/client router cache** may still cause issues. **Relevant Code Locations**: - `src/app/admin/tournaments/[id]/schedule/page.tsx:14-16` - `src/app/api/tournaments/[id]/schedule/route.ts:191-222` (POST transaction) ### 2. Prisma Client and Transaction Isolation **Finding**: The POST endpoint uses `prisma.$transaction` to create rounds and matchups. In production with PostgreSQL, transaction isolation levels and connection pooling can cause visibility delays. **Evidence**: ```typescript // src/app/api/tournaments/[id]/schedule/route.ts:191 const created = await prisma.$transaction( schedule.map((round) => prisma.tournamentRound.create({...}) ) ) ``` **Potential Issues**: - **Read Committed Isolation**: PostgreSQL's default `READ COMMITTED` isolation level ensures that once a transaction commits, subsequent queries see the new data. However, if the browser refresh happens immediately after the POST response, there might be a race condition. - **Connection Pooling**: The Prisma client uses connection pooling. If the GET request (page load) uses a different connection than the POST request, and there's a replication delay (unlikely with SQLite/PostgreSQL single instance), it could see stale data. **Evidence Locations**: - `src/lib/prisma.ts:13-35` (Prisma client initialization) - `src/app/api/tournaments/[id]/schedule/route.ts:191-222` (Transaction block) ### 3. Client-Side Router Cache **Finding**: The Next.js App Router maintains a client-side cache for visited routes. Even when the server component revalidates, the client might serve a cached version from the client-side navigation cache. **Evidence from research**: - The GitHub discussion #51612 shows that `router.push` and browser refresh can still serve stale data due to client-side caching. - The `ScheduleGenerator` component uses `fetch` to POST data but doesn't trigger a router refresh or invalidate the client cache. **Code Locations**: - `src/components/ScheduleGenerator.tsx:27-29` (POST request) - `src/components/ScheduleGenerator.tsx:84` (Only calls `window.location.reload()` on DELETE, not POST) ### 4. Production vs Development Differences **Finding**: Development mode (`next dev`) has more lenient caching behavior. Production builds (`next start`) aggressively cache by default. **Evidence**: - The test `schedule-tab.test.ts` passes in development but fails in production. - The `ScheduleGenerator` component doesn't use `revalidatePath` or `revalidateTag` after successful POST. --- ## Specific Technical Findings ### Finding 1: Missing Cache Invalidation After POST **Location**: `src/components/ScheduleGenerator.tsx:43-49` **Issue**: After a successful POST request, the component updates local state (`result`) but doesn't: 1. Call `revalidatePath` (requires Server Action) 2. Call `revalidateTag` (requires Server Action) 3. Trigger a router refresh 4. Force a page reload **Current Behavior**: ```typescript const handleGenerate = async () => { // ... POST request ... const data = await response.json() setResult({ roundsCreated: data.roundsCreated, matchupsCreated: data.matchupsCreated, }) setIsGenerating(false) // ❌ No cache invalidation } ``` **Expected Behavior**: After POST, the page should re-fetch data to show newly created rounds. ### Finding 2: Prisma Client Singleton Pattern **Location**: `src/lib/prisma.ts:37-39` **Issue**: The Prisma client is a singleton, which is correct. However, in production with connection pooling, there might be delays in visibility across connections. **Current Code**: ```typescript export const prisma = globalForPrisma.prisma ?? createPrismaClient() if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma ``` **Note**: This is correct pattern, but production connection pooling behavior differs from development. ### Finding 3: Server Component Data Fetching **Location**: `src/app/admin/tournaments/[id]/schedule/page.tsx:26-50` **Issue**: The server component fetches data directly with Prisma. While `revalidate = 0` ensures the server re-renders on each request, the client might cache the response. **Current Code**: ```typescript export const dynamic = "force-dynamic" export const revalidate = 0 export default async function TournamentSchedulePage({ params }: PageProps) { const tournament = await prisma.event.findUnique({ where: { id: tournamentId }, include: { rounds: { ... } } }) // ... } ``` **Note**: This should work correctly, but client-side router cache might interfere. --- ## Potential Fixes ### Fix 1: Implement Server Actions for Cache Invalidation **Approach**: Convert the schedule generation to use Server Actions with `revalidatePath`. **Implementation**: ```typescript // src/app/actions/schedule.ts 'use server' import { revalidatePath } from 'next/cache' import { prisma } from '@/lib/prisma' import { generateRoundRobin, /* ... */ } from '@/lib/schedule-generator' export async function generateSchedule(tournamentId: number) { // ... existing logic from route.ts ... // After successful creation await prisma.$transaction(/* ... */) // Revalidate the schedule page revalidatePath(`/admin/tournaments/${tournamentId}/schedule`) revalidatePath(`/admin/tournaments/${tournamentId}`) return { success: true, roundsCreated: created.length } } ``` **Update ScheduleGenerator component**: ```typescript // src/components/ScheduleGenerator.tsx import { generateSchedule } from '@/app/actions/schedule' const handleGenerate = async () => { const result = await generateSchedule(tournamentId) if (result.success) { setResult({ roundsCreated: result.roundsCreated, matchupsCreated: /* calculate from result */, }) // Router automatically revalidates due to revalidatePath } } ``` ### Fix 2: Force Router Refresh After POST **Approach**: Use `router.refresh()` after successful POST to invalidate client cache. **Implementation**: ```typescript // src/components/ScheduleGenerator.tsx 'use client' import { useRouter } from 'next/navigation' export function ScheduleGenerator({ tournamentId, /* ... */ }) { const router = useRouter() const handleGenerate = async () => { // ... POST request ... if (response.ok) { // Force router to re-fetch server component data router.refresh() // Or force full page reload as fallback // window.location.reload() } } } ``` ### Fix 3: Disable Fetch Caching Explicitly **Approach**: Even though we use Prisma, ensure any internal fetches don't cache. **Implementation**: ```typescript // src/app/api/tournaments/[id]/schedule/route.ts export async function GET(request: Request, { params }: RouteParams) { // Add cache control headers const response = NextResponse.json({ rounds: tournament.rounds }) response.headers.set('Cache-Control', 'no-store, max-age=0') return response } ``` ### Fix 4: Add Delay/Retry Logic in Tests **Approach**: For Playwright tests, add explicit wait for data visibility. **Implementation**: ```typescript // e2e/schedule-tab.test.ts test('Schedule page displays generated rounds and matchups', async ({ page }) => { // ... navigate to schedule page ... // Wait for rounds to be visible with retry logic await expect(page.locator('text=Round 1')).toBeVisible({ timeout: 10000 }) // Additional verification await expect(page.locator('text=Alice + Bob')).toBeVisible() }) ``` ### Fix 5: Database Transaction Optimization **Approach**: Ensure transaction commits fully before returning response. **Implementation**: ```typescript // src/app/api/tournaments/[id]/schedule/route.ts const created = await prisma.$transaction( schedule.map((round) => prisma.tournamentRound.create({ data: { /* ... */ }, include: { /* ... */ } // Eager load to ensure data is available }) ), { isolationLevel: 'ReadCommitted', // Explicit isolation level maxWait: 5000, // Increase wait time timeout: 10000, // Increase timeout } ) ``` --- ## Recommended Solution ### Immediate Fix (Quick) 1. **Update `ScheduleGenerator.tsx`** to use `router.refresh()` after POST: ```typescript import { useRouter } from 'next/navigation' const router = useRouter() const handleGenerate = async () => { // ... POST logic ... if (response.ok) { router.refresh() } } ``` 2. **Add cache control headers** to the GET endpoint: ```typescript // In GET handler const response = NextResponse.json({ rounds: tournament.rounds }) response.headers.set('Cache-Control', 'no-store, max-age=0') return response ``` ### Long-term Fix (Recommended) 1. **Migrate to Server Actions** for schedule generation: - Use `'use server'` directive - Call `revalidatePath` after mutations - Eliminate need for separate API route 2. **Implement proper cache tagging**: - Tag fetch requests with `next: { tags: ['schedule'] }` - Use `revalidateTag('schedule')` after mutations 3. **Update test patterns**: - Ensure tests wait for server component revalidation - Use `page.waitForLoadState('networkidle')` after mutations --- ## Verification Steps 1. **Test in production build**: ```bash npm run build npm run start ``` 2. **Verify data flow**: - Create schedule via UI - Refresh page immediately - Verify rounds display correctly 3. **Check server logs**: - Look for revalidation messages - Verify Prisma query execution 4. **Run acceptance tests**: ```bash npm run test:acceptance ``` --- ## References - Next.js App Router Caching: https://nextjs.org/docs/app/building-your-application/data-fetching/caching - Server Actions: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations - GitHub Discussion #51612: https://github.com/vercel/next.js/discussions/51612 - Prisma Transactions: https://www.prisma.io/docs/orm/prisma-client/queries/transactions