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

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

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: docker/setup-buildx-action@v3

      - name: Build Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          tags: my-app:${{ github.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:${{ github.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 is a good starting point.

Alternative: Tunnel a locally-served app (cloud-compute)

If your CI environment can't build a Docker image, you can fall back to the tunnel-based cloud-compute action. This requires keeping a server alive in the runner and uses companion assets to make Next.js static asset serving fast.

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

Install dependency:

npm install seedrandom

Create lib/random.ts:

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:

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

<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 = 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

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

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

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

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

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

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

- 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 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:

- 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 = 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

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. 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

See complete working example:


See Also