Next.js App Router - Complete Setup Guide

Complete guide for setting up Meticulous with Next.js applications using the App Router (Next.js 13+).


Overview

Next.js App Router introduces React Server Components, server actions, and streaming - all of which require special handling for automated testing. This guide covers:

  • Recorder installation in App Router layout
  • CI/CD configuration with companion assets optimization
  • Server component testing with deterministic rendering
  • Common patterns for authentication, data fetching, and more

Prerequisites:


Quick Start

1. Install Recorder in Root Layout

Add the Meticulous recorder script to your root layout before any other scripts.

File: app/layout.tsx

import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Your App',
  description: 'Your app description',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <head>
        {/* Meticulous recorder - MUST be first script */}
        {/* Replace YOUR_PROJECT_ID with your project ID from the dashboard */}
        <script
          data-project-id="YOUR_PROJECT_ID"
          src="https://snippet.meticulous.ai/v1/meticulous.js"
        />
      </head>
      <body>{children}</body>
    </html>
  )
}

Important: The recorder must load before Next.js client-side JavaScript to capture all events.

2. Configure GitHub Actions Workflow

Create a workflow that builds your app and uses companion assets for optimal performance.

File: .github/workflows/meticulous.yml

name: Meticulous Tests

on:
  push:
    branches: [main]
  pull_request: {}
  workflow_dispatch: {}

permissions:
  actions: write
  contents: read
  issues: write
  pull-requests: write
  statuses: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build Next.js app
        run: npm run build
        env:
          NODE_ENV: production

      - name: Prepare companion assets
        run: |
          mkdir -p companion-assets/_next
          cp -r .next/static companion-assets/_next/

      - name: Start app
        run: |
          npm start &
          npx wait-on http://localhost:3000 --timeout 60000
        env:
          PORT: 3000

      - name: Run Meticulous tests
        uses: alwaysmeticulous/report-diffs-action/cloud-compute@v1
        with:
          api-token: ${{ secrets.METICULOUS_API_TOKEN }}
          app-url: "http://localhost:3000"
          companion-assets-folder: "companion-assets"
          companion-assets-regex: "^/_next/static/"

3. Add API Token Secret

  1. Get your API token from Meticulous dashboard → Project Settings
  2. Go to GitHub repo → Settings → Secrets and variables → Actions
  3. Create secret named METICULOUS_API_TOKEN with your token

4. Configure Project Settings

Visit your project settings in Meticulous dashboard:

  1. Go to Network Stubbing section
  2. Select: "Stub all requests, apart from requests for server components and static assets"
  3. Save settings

This ensures React Server Components work correctly during test replay.


Network Stubbing for Server Components

How It Works

Next.js App Router makes requests to itself for Server Components (RSC protocol). These requests look like:

GET /?_rsc=123abc

Meticulous behavior:

  • Client-side API calls: Automatically stubbed with recorded responses
  • Server Component requests: Passed through to running app (not stubbed)
  • Static assets: Served via companion assets (faster than tunnel)

Why This Matters

Server Components render on the server and stream HTML to the client. Stubbing these requests would break the App Router's streaming architecture.

Solution: Configure network stubbing (step 4 above) to allow Server Component requests through while stubbing external APIs.


Ensuring Deterministic Rendering

Why Determinism Matters

Meticulous compares screenshots from before/after your code change. If rendering is non-deterministic (produces different HTML on each run), you'll get false positive diffs.

Common sources of non-determinism in Server Components:

  • Math.random()
  • Date.now() or new Date()
  • crypto.randomUUID()
  • External API calls with changing data

Handling Math.random() in Server Components

If you use Math.random() in Server Components, add deterministic seeding:

Install dependency:

npm install seedrandom

Create/update instrumentation.ts:

import { headers } from 'next/headers';
import { alea } from 'seedrandom';

export function register() {
  const nativeRandom = Math.random.bind(Math);

  Math.random = () => {
    try {
      if (headers().get('meticulous-is-test') === '1') {
        // Use deterministic seed from Meticulous
        return alea(headers().get('meticulous-simulated-date') ?? undefined)();
      }
    } catch (e) {
      // Fallback to native random
    }
    return nativeRandom();
  };
}

Requirements:

  • Next.js 14 or later (for instrumentation.ts support)
  • seedrandom package

How it works:

  1. Meticulous sets meticulous-is-test: 1 header during tests
  2. meticulous-simulated-date provides deterministic seed
  3. Math.random() returns predictable values during tests
  4. Production behavior unchanged

Handling Timestamps in Server Components

For time-based rendering (e.g., "Posted 5 minutes ago"), use the simulated date header:

import { headers } from 'next/headers'

const getCurrentDate = (): Date => {
  // Use simulated date during tests for determinism
  const simulatedDate = headers().get('meticulous-simulated-date');

  if (simulatedDate) {
    return new Date(Date.parse(simulatedDate));
  }

  return new Date();
}

// Usage in Server Component
export default async function PostCard() {
  const currentDate = getCurrentDate();
  const timeSincePost = calculateTimeDiff(post.createdAt, currentDate);

  return (
    <div>
      <p>Posted {timeSincePost} ago</p>
    </div>
  );
}

Format: meticulous-simulated-date is RFC 7231 format (e.g., "Fri, 17 May 2024 13:35:20 GMT")


Testing Determinism Locally

Use a browser extension to simulate Meticulous headers:

  1. Install ModHeader
  2. Add headers:
    • meticulous-is-test: 1
    • meticulous-simulated-date: Fri, 17 May 2024 13:35:20 GMT
  3. Visit your page and reload multiple times
  4. Content should be identical on each reload

Alternative: Ignore Changing Elements

If you can't make an element deterministic, ignore it in screenshots:

Option 1: Add CSS class

<div className="meticulous-ignore">
  Posted {timeSincePost} ago
</div>

Option 2: Configure in project settings

Go to Project Settings → Screenshots & Flakes → Add CSS selectors to ignore:

.timestamp
.relative-time
[data-testid="posted-time"]

Learn more: Fix False Positive Diffs


Complete Setup Example

File Structure

your-app/
├── app/
│   ├── layout.tsx              # Recorder installation
│   ├── page.tsx                # Server Component
│   └── components/
│       └── client-component.tsx
├── instrumentation.ts          # Math.random() seeding
├── lib/
│   └── date-utils.ts           # getCurrentDate helper
├── .github/
│   └── workflows/
│       └── meticulous.yml      # CI/CD
└── package.json

Example: Server Component with Deterministic Rendering

File: lib/date-utils.ts

import { headers } from 'next/headers'

export const getCurrentDate = (): Date => {
  const simulatedDate = headers().get('meticulous-simulated-date');
  return simulatedDate ? new Date(Date.parse(simulatedDate)) : new Date();
}

export const isMeticulousTest = (): boolean => {
  return headers().get('meticulous-is-test') === '1';
}

File: app/posts/[id]/page.tsx

import { getCurrentDate, isMeticulousTest } from '@/lib/date-utils'
import { formatDistanceToNow } from 'date-fns'

interface Post {
  id: string
  title: string
  content: string
  createdAt: Date
  author: {
    name: string
    avatar: string
  }
}

async function getPost(id: string): Promise<Post> {
  // This fetch is automatically stubbed by Meticulous
  const res = await fetch(`https://api.example.com/posts/${id}`)
  return res.json()
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id)
  const currentDate = getCurrentDate()

  // Calculate time difference using deterministic date
  const timeAgo = formatDistanceToNow(post.createdAt, {
    addSuffix: true,
    includeSeconds: false
  })

  return (
    <article>
      <h1>{post.title}</h1>

      <div className="author-info">
        <img src={post.author.avatar} alt={post.author.name} />
        <div>
          <p>{post.author.name}</p>
          <p className="text-gray-500">
            Posted {timeAgo}
          </p>
        </div>
      </div>

      <div className="content">
        {post.content}
      </div>

      {isMeticulousTest() && (
        /* Show test indicator in tests */
        <div className="bg-yellow-100 p-2">Running as test</div>
      )}
    </article>
  )
}

Common Patterns

Pattern 1: Detect Test Mode in Server Components

import { headers } from 'next/headers'

export default async function Page() {
  const isTest = headers().get('meticulous-is-test') === '1'

  if (isTest) {
    // Skip expensive operations during tests
    // Or use mock data
  }

  return <div>...</div>
}

Pattern 2: Bypass Authentication in Tests

import { headers } from 'next/headers'
import { redirect } from 'next/navigation'

export default async function ProtectedPage() {
  const isTest = headers().get('meticulous-is-test') === '1'

  if (!isTest) {
    const session = await getServerSession()
    if (!session) {
      redirect('/login')
    }
  }

  // Render protected content
  return <div>Protected content</div>
}

Pattern 3: Use Mock Data for Tests

import { headers } from 'next/headers'

async function getData() {
  const isTest = headers().get('meticulous-is-test') === '1'

  if (isTest) {
    // Return deterministic test data
    return {
      id: 'test-id-123',
      name: 'Test User',
      createdAt: new Date('2024-01-01T00:00:00Z')
    }
  }

  // Fetch real data
  const res = await fetch('https://api.example.com/data')
  return res.json()
}

Pattern 4: Handle Feature Flags

import { headers } from 'next/headers'

async function getFeatureFlags() {
  const isTest = headers().get('meticulous-is-test') === '1'

  if (isTest) {
    // Use deterministic flags during tests
    return {
      newCheckout: true,
      experimentalUI: false
    }
  }

  // Fetch real flags from feature flag service
  return await fetchFlags()
}

CI/CD Configuration Details

Why Companion Assets?

Next.js static assets (/_next/static/) are large and numerous. Serving them through the tunnel is slow.

Without companion assets:

Test Duration: ~5 minutes
Tunnel Traffic: ~50MB per test run

With companion assets:

Test Duration: ~2 minutes
Tunnel Traffic: ~5MB per test run
Performance: 60% faster

Companion Assets Setup

Step 1: Copy static files after build

- name: Build Next.js app
  run: npm run build

- name: Prepare companion assets
  run: |
    mkdir -p companion-assets/_next
    cp -r .next/static companion-assets/_next/
    ls -la companion-assets  # Verify files copied

Step 2: Configure Meticulous action

- name: Run Meticulous tests
  uses: alwaysmeticulous/report-diffs-action/cloud-compute@v1
  with:
    api-token: ${{ secrets.METICULOUS_API_TOKEN }}
    app-url: "http://localhost:3000"
    companion-assets-folder: "companion-assets"
    companion-assets-regex: "^/_next/static/"

How it works:

  1. Meticulous intercepts requests matching ^/_next/static/
  2. Files are served from companion-assets/_next/static/
  3. Other requests go through tunnel to running app

Environment Variables

Build-time variables (prefixed with NEXT_PUBLIC_):

- name: Build Next.js app
  run: npm run build
  env:
    NEXT_PUBLIC_API_URL: "http://localhost:3000/api"
    NODE_ENV: production

Runtime variables (server-side only):

- name: Start app
  run: npm start &
  env:
    DATABASE_URL: "postgresql://..."
    API_SECRET: ${{ secrets.API_SECRET }}

Troubleshooting

Issue: "Failed to fetch RSC payload"

Symptom: Console errors about RSC fetch failures

Cause: Network stubbing is blocking Server Component requests

Fix: Update project settings to allow Server Component requests (see step 4 in Quick Start)


Issue: Timestamps Cause False Positives

Symptom: Diffs showing "Posted 5 min ago" vs "Posted 6 min ago"

Causes:

  1. Not using meticulous-simulated-date header
  2. Using Date.now() or new Date() in Server Components

Fix: Use getCurrentDate() helper (see Handling Timestamps section)


Issue: Math.random() Produces Different Results

Symptom: Random UUIDs, shuffled arrays, or randomized content causes diffs

Fix: Install seedrandom and setup instrumentation.ts (see Handling Math.random() section)


Issue: Companion Assets Not Loading

Symptom: Console errors for /_next/static/ files, or slow test runs

Checks:

  1. Verify folder exists: ls -la companion-assets/_next/static
  2. Check files were copied: Should see CSS/JS files
  3. Verify regex pattern matches: ^/_next/static/ should match /_next/static/chunks/123.js
  4. Check workflow syntax: Both companion-assets-folder and companion-assets-regex required

Debug:

- name: Debug companion assets
  run: |
    echo "Checking companion assets..."
    ls -la companion-assets/_next/static || echo "Directory not found"
    find companion-assets -type f | head -10

Issue: App Doesn't Start in CI

Symptom: "ECONNREFUSED" or "Failed to connect to http://localhost:3000"

Common causes:

  1. Build failed silently
  2. Port already in use
  3. Missing environment variables
  4. App requires database connection

Debug steps:

- name: Start app with logging
  run: |
    npm start > app.log 2>&1 &
    sleep 5
    cat app.log  # Check for startup errors

- name: Verify app is running
  run: |
    curl http://localhost:3000 || echo "App not responding"
    npx wait-on http://localhost:3000 --timeout 60000

Issue: Authentication Blocks Tests

Symptom: Tests fail because pages redirect to login

Solutions:

Option 1: Bypass auth in tests (recommended)

import { headers } from 'next/headers'

const isMeticulousTest = () => headers().get('meticulous-is-test') === '1'

export default async function ProtectedLayout({ children }) {
  if (!isMeticulousTest()) {
    const session = await getServerSession()
    if (!session) redirect('/login')
  }

  return <>{children}</>
}

Option 2: Mock authentication

if (isMeticulousTest()) {
  // Return mock session for tests
  return {
    user: { id: 'test-user', name: 'Test User' }
  }
}

See full guide: Troubleshoot Authentication


Advanced Configuration

Custom Tunnel Options

If you need to proxy multiple ports or use HTTPS:

- name: Run Meticulous tests
  uses: alwaysmeticulous/report-diffs-action/cloud-compute@v1
  with:
    api-token: ${{ secrets.METICULOUS_API_TOKEN }}
    app-url: "http://localhost:3000"
    proxy-all-urls: true  # Proxy all domains, not just app-url
    companion-assets-folder: "companion-assets"
    companion-assets-regex: "^/_next/static/"

See: Tunnel Advanced Options


Monorepo Setup

If your Next.js app is in a subdirectory:

- name: Install dependencies
  working-directory: ./apps/frontend
  run: npm ci

- name: Build app
  working-directory: ./apps/frontend
  run: npm run build

- name: Prepare companion assets
  working-directory: ./apps/frontend
  run: |
    mkdir -p companion-assets/_next
    cp -r .next/static companion-assets/_next/

- name: Start app
  working-directory: ./apps/frontend
  run: npm start &

- name: Run Meticulous tests
  uses: alwaysmeticulous/report-diffs-action/cloud-compute@v1
  with:
    api-token: ${{ secrets.METICULOUS_API_TOKEN }}
    app-url: "http://localhost:3000"
    companion-assets-folder: "./apps/frontend/companion-assets"
    companion-assets-regex: "^/_next/static/"

Testing Best Practices

1. Record Real User Sessions

Record sessions in staging or production (with appropriate privacy controls):

// Only load recorder in staging/production
const shouldLoadRecorder =
  process.env.NEXT_PUBLIC_ENV === 'staging' ||
  process.env.NEXT_PUBLIC_ENV === 'production'

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        {shouldLoadRecorder && (
          <script
            data-project-id={process.env.NEXT_PUBLIC_METICULOUS_PROJECT_ID}
            src="https://snippet.meticulous.ai/v1/meticulous.js"
          />
        )}
      </head>
      <body>{children}</body>
    </html>
  )
}

2. Curate Your Test Suite

Review recorded sessions in the dashboard and select high-value flows:

  • Critical user journeys (signup, checkout, etc.)
  • High-traffic pages
  • Recently changed features
  • Edge cases

3. Handle Dynamic Content

For content that changes frequently (ads, recommendations, live data):

<div className="meticulous-ignore">
  {/* Content that changes frequently */}
</div>

4. Test Locally Before CI

Run tests locally to catch issues faster:

# Start your app
npm run dev

# In another terminal, run Meticulous CLI
npx @alwaysmeticulous/cli simulate \
  --sessionId="YOUR_SESSION_ID" \
  --appUrl="http://localhost:3000"

Migration from Pages Router

If you're migrating from Pages Router to App Router:

  1. Keep recorder in head: Move from _document.tsx to app/layout.tsx
  2. Update network stubbing: Enable Server Component request passthrough
  3. Add instrumentation.ts: For Math.random() if used in Server Components
  4. Update date handling: Use getCurrentDate() helper in Server Components
  5. Test thoroughly: Server Components behave differently than client components

Example Repository

See complete working example:


See Also