> Meticulous records real user sessions from a web app and replays them against each commit to catch visual regressions.
>
> Full AI-readable docs index (complete project setup journey + every page as markdown): https://app.meticulous.ai/llms.txt

# 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**:
- Next.js 13+ using App Router
- Basic familiarity with [Meticulous concepts](/docs/onboarding-guide)

---

## 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`

```typescript
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

The recommended approach for Next.js apps is to **build a Docker image of your app and have Meticulous host it** via the `upload-container` action. The container only needs to live for the duration of the upload step — Meticulous runs it on its own infrastructure for the actual test run.

**File**: `.github/workflows/meticulous.yml`

```yaml
name: Meticulous Tests

on:
  push:
    branches: [main]
  pull_request: {}
  workflow_dispatch:
    inputs:
      meticulous-commit-sha:
        description: Commit Meticulous has asked this run to build. Defaults to the branch head.
        required: false

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

env:
  # Prefer the dispatched commit when set; otherwise the PR head. On pull_request github.sha is the merge commit,
  # not the PR head SHA that Meticulous looks up.
  METICULOUS_COMMIT_SHA: ${{ github.event.inputs['meticulous-commit-sha'] || (github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha) }}

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

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ env.METICULOUS_COMMIT_SHA }}

      - uses: docker/setup-buildx-action@v3

      - name: Build Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          tags: my-app:${{ env.METICULOUS_COMMIT_SHA }}
          platforms: linux/amd64
          push: false
          load: true

      - name: Run Meticulous tests
        uses: alwaysmeticulous/report-diffs-action/upload-container@v1
        with:
          api-token: ${{ secrets.METICULOUS_API_TOKEN }}
          image-tag: my-app:${{ env.METICULOUS_COMMIT_SHA }}
          # Optional: set if your container does not respect the PORT env var
          container-port: 3000
          # Optional: extra runtime env vars for the container
          container-env: |
            NODE_ENV=production
```

Your Dockerfile should:
- Build for `linux/amd64`
- Run `next start` (or equivalent) in the foreground
- Listen on the `PORT` env var (or set `container-port` to match)
- Respond `2xx` to a health-check endpoint (defaults to `GET /`; override with `container-health-check-endpoint`)

If you don't already have a Dockerfile, the [Next.js with Docker example](https://github.com/vercel/next.js/tree/canary/examples/with-docker) is a good starting point.

### 3. Add API Token Secret

1. Get your API token from [Meticulous dashboard](https://app.meticulous.ai) → 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 directly by your app (not stubbed)

### 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, prefer a request-scoped deterministic random helper:

**Install dependency**:

```bash
npm install seedrandom
```

**Create `lib/random.ts`**:

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

export const createDeterministicRandom = async (): Promise<() => number> => {
  const requestHeaders = await headers();
  const isMeticulousTest = requestHeaders.get('meticulous-is-test') === '1';

  if (!isMeticulousTest) {
    return Math.random;
  }

  const seed =
    requestHeaders.get('meticulous-simulated-date') ?? 'meticulous-test';
  const random = alea(seed);

  return () => random();
};
```

**Requirements**:
- `seedrandom` package

**How it works**:
1. Meticulous sets `meticulous-is-test: 1` header during tests
2. If you've configured a simulated date header in your Meticulous project settings (using the Simulated Date template), it provides a deterministic seed
3. Calls to your helper return predictable values during tests
4. Production behavior unchanged

---

### Handling Timestamps in Server Components

For time-based rendering (e.g., "Posted 5 minutes ago"), you can configure Meticulous to send a simulated date header. Go to your
project settings (Settings > Custom Request Headers), add a header named `meticulous-simulated-date`, and select the **Simulated Date**
template for the value. Then use it in your server code:

```typescript
import { headers } from 'next/headers'

const getCurrentDate = async (): Promise<Date> => {
  const requestHeaders = await headers()

  // If a simulated date header is configured in Meticulous project settings, use it for determinism
  const simulatedDate = requestHeaders.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 = await getCurrentDate()
  const timeSincePost = calculateTimeDiff(post.createdAt, currentDate)

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

**Format**: The simulated date is in 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](https://chromewebstore.google.com/detail/modheader-modify-http-hea/idgpnmonknjnojddfkpgkljpfnnfcklj)
2. Add headers:
   - `meticulous-is-test`: `1`
   - If you've configured a simulated date header, add it too (e.g. `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**

```typescript
<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](/docs/how-to/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`

```typescript
import { headers } from 'next/headers'

export const getCurrentDate = async (): Promise<Date> => {
  const requestHeaders = await headers()

  // If you've configured a simulated date header in Meticulous project settings, use it for determinism
  const simulatedDate = requestHeaders.get('meticulous-simulated-date')
  return simulatedDate ? new Date(Date.parse(simulatedDate)) : new Date()
}

export const isMeticulousTest = async (): Promise<boolean> => {
  const requestHeaders = await headers()
  return requestHeaders.get('meticulous-is-test') === '1'
}
```

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

```typescript
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: Promise<{ id: string }>
}) {
  const { id } = await params
  const post = await getPost(id)
  const currentDate = await 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>

      {(await 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

```typescript
import { headers } from 'next/headers'

export default async function Page() {
  const requestHeaders = await headers()
  const isTest = requestHeaders.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

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

export default async function ProtectedPage() {
  const requestHeaders = await headers()
  const isTest = requestHeaders.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

```typescript
import { headers } from 'next/headers'

async function getData() {
  const requestHeaders = await headers()
  const isTest = requestHeaders.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

```typescript
import { headers } from 'next/headers'

async function getFeatureFlags() {
  const requestHeaders = await headers()
  const isTest = requestHeaders.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

> **info: The sections below apply to the cloud-compute (tunnel) path only**
> If you're using `upload-container` (the recommended approach), Meticulous serves your app from the uploaded image — there's no tunnel and no need for companion assets. The companion-assets, custom-tunnel-options, and similar sections below only apply if you're using `cloud-compute`.

### 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**

```yaml
- 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**

```yaml
- 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_`):

```yaml
- 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):

```yaml
- 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 a simulated date header for server-side rendering
2. Using `Date.now()` or `new Date()` in Server Components

**Fix**: Configure a simulated date header in Meticulous project settings using the Simulated Date template, then use the `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**:
```yaml
- 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**:

```yaml
- 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)

```typescript
import { headers } from 'next/headers'

const isMeticulousTest = async () => {
  const requestHeaders = await headers()
  return requestHeaders.get('meticulous-is-test') === '1'
}

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

  return <>{children}</>
}
```

**Option 2: Mock authentication**

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

See full guide: [Troubleshoot Authentication](/docs/how-to/troubleshoot-auth)

---

## Advanced Configuration

### Custom Tunnel Options

If you need to proxy multiple ports or use HTTPS:

```yaml
- 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](/docs/how-to/tunnel-advanced-options)

---

### Monorepo Setup

If your Next.js app is in a subdirectory:

```yaml
- 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):

```typescript
// 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):

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

### 4. Test Locally Before CI

Run tests locally to catch issues faster:

```bash
# 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. **Update Math.random() calls**: Use `createDeterministicRandom()` helper 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

The configuration above is a complete App Router setup.

---

## See Also

- [Onboarding Guide](/docs/onboarding-guide) - General Meticulous setup
- [Network Stubbing Explanation](/docs/concepts/network-recording-and-patching) - How API mocking works
- [Fix False Positives](/docs/how-to/fix-false-positive-diffs) - Handle non-deterministic content
- [Troubleshoot Authentication](/docs/how-to/troubleshoot-auth) - Auth patterns and solutions
- [Companion Assets Guide](/docs/how-to/companion-assets-advanced) - Deep dive into static asset optimization
