Next.js Pages Router - Complete Setup Guide

Complete guide for setting up Meticulous with Next.js applications using the Pages Router (Next.js 12 and earlier, or Next.js 13+ without App Router).


Overview

The Pages Router is the traditional Next.js routing system. This guide covers:

  • Recorder installation in _document.tsx
  • CI/CD configuration with companion assets
  • Authentication handling
  • Common patterns and troubleshooting

Prerequisites:


Quick Start

Step 1: Install Recorder in _document.tsx

Add the Meticulous recorder script to your custom Document component before any other scripts.

File: pages/_document.tsx

import { Html, Head, Main, NextScript } from 'next/document'

export default function Document() {
  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>
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

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

If you don't have _document.tsx: Create it in pages/_document.tsx with the code above.


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

The recommended approach is to build a Docker image of your Next.js 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.

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 keeps a server alive in the runner and uses companion assets to make Next.js static asset serving fast.

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

Step 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

Companion Assets

The companion-assets pattern only applies to the cloud-compute (tunnel) workflow. If you're using the recommended upload-container action, Meticulous serves your app directly from the uploaded image and there's nothing additional to configure.

Why Use Companion Assets?

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

Performance improvement:

  • Without companion assets: ~5 minutes test duration
  • With companion assets: ~2 minutes test duration (60% faster)

Setup

The workflow above already includes companion assets setup:

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

- name: Run Meticulous tests
  with:
    companion-assets-folder: "companion-assets"
    companion-assets-regex: "^/_next/static/"

How it works:

  1. Build creates .next/static/ with all static assets
  2. Copy .next/static/ to companion-assets/_next/static/
  3. Meticulous serves these files directly, bypassing the tunnel

Learn more: Companion Assets Guide


Common Patterns

Pattern 1: Detect Test Mode

Use window.Meticulous.isRunningAsTest to detect when running as a test:

// In any component
function MyComponent() {
  const isTest = window.Meticulous?.isRunningAsTest

  if (isTest) {
    // Skip animations, use test data, etc.
  }

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

Pattern 2: Bypass Authentication

// pages/_app.tsx
import { useEffect } from 'react'
import { useRouter } from 'next/router'

function MyApp({ Component, pageProps }) {
  const router = useRouter()

  useEffect(() => {
    if (window.Meticulous?.isRunningAsTest) {
      // Mock authentication for tests
      localStorage.setItem('auth-token', 'test-token')
      localStorage.setItem('user', JSON.stringify({
        id: 'test-user',
        name: 'Test User',
        email: 'test@example.com'
      }))
    }
  }, [])

  return <Component {...pageProps} />
}

Pattern 3: Server-Side Detection

Check for meticulous-is-test header in getServerSideProps:

export const getServerSideProps = async (context) => {
  const { req } = context
  const isTest = req.headers['meticulous-is-test'] === '1'

  if (isTest) {
    // Skip auth redirect, use test data, etc.
    return {
      props: {
        user: { id: 'test-user', name: 'Test User' }
      }
    }
  }

  // Normal server-side logic
  const session = await getSession(context)
  if (!session) {
    return {
      redirect: {
        destination: '/login',
        permanent: false,
      }
    }
  }

  return {
    props: {
      user: session.user
    }
  }
}

Pattern 4: Handle Dynamic Timestamps

Ignore elements with frequently changing content:

// Add meticulous-ignore class
<div className="meticulous-ignore">
  Posted {formatDistanceToNow(post.createdAt)} ago
</div>

Or configure in project settings to ignore CSS selectors globally.


Complete Example

File Structure

your-app/
├── pages/
│   ├── _app.tsx              # App wrapper
│   ├── _document.tsx         # Recorder installation
│   ├── index.tsx             # Home page
│   └── dashboard.tsx         # Protected page
├── lib/
│   └── auth.ts               # Auth utilities
├── .github/
│   └── workflows/
│       └── meticulous.yml    # CI/CD
└── package.json

Example: Protected Page

File: pages/dashboard.tsx

import { GetServerSideProps } from 'next'
import { getSession } from 'next-auth/react'

interface DashboardProps {
  user: {
    id: string
    name: string
    email: string
  }
}

export const getServerSideProps: GetServerSideProps<DashboardProps> = async (context) => {
  const isTest = context.req.headers['meticulous-is-test'] === '1'

  if (isTest) {
    // Bypass auth during tests
    return {
      props: {
        user: {
          id: 'test-user-123',
          name: 'Test User',
          email: 'test@example.com'
        }
      }
    }
  }

  // Normal auth flow
  const session = await getSession(context)

  if (!session) {
    return {
      redirect: {
        destination: '/login?redirect=/dashboard',
        permanent: false,
      }
    }
  }

  return {
    props: {
      user: session.user
    }
  }
}

export default function Dashboard({ user }: DashboardProps) {
  return (
    <div>
      <h1>Welcome, {user.name}!</h1>
      <p>Email: {user.email}</p>
    </div>
  )
}

CI/CD Configuration Details

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

Custom Build Scripts

If you have a custom build process:

- name: Build app
  run: |
    npm run build:custom
    npm run postbuild:assets

- name: Prepare companion assets
  run: |
    mkdir -p companion-assets/_next
    cp -r .next/static companion-assets/_next/
    # Copy any additional static assets
    cp -r public/static companion-assets/static

Troubleshooting

Issue: Recorder Not Loading

Symptom: window.Meticulous is undefined

Checks:

  1. Verify _document.tsx has recorder script in <Head>
  2. Check project ID is correct
  3. Check for CSP blocking (console errors)
  4. Verify script loads before other scripts

Fix: Ensure recorder is in <Head>, not <body>:

<Head>
  <script data-project-id="..." src="https://snippet.meticulous.ai/v1/meticulous.js" />
  {/* Other head elements */}
</Head>

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

Debug:

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

- 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

Solution 1: Bypass auth in getServerSideProps

export const getServerSideProps = (context) => {
  const isTest = context.req.headers['meticulous-is-test'] === '1'

  if (isTest) {
    return { props: { user: mockUser } }
  }

  // Normal auth flow
}

Solution 2: Mock auth in _app.tsx

useEffect(() => {
  if (window.Meticulous?.isRunningAsTest) {
    // Set mock auth data
    localStorage.setItem('token', 'test-token')
  }
}, [])

See full guide: Troubleshoot Authentication


Issue: Companion Assets Not Loading

Symptom: Console errors for /_next/static/ files

Checks:

  1. Verify folder exists: ls -la companion-assets/_next/static
  2. Check files were copied after build
  3. Verify regex pattern: ^/_next/static/

Fix: Ensure build completes before copying:

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

- name: Verify build output
  run: ls -la .next/static

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

Issue: False Positive Diffs

Symptom: Tests show diffs for content that hasn't changed

Common causes:

  1. Timestamps: "Posted 5 min ago" vs "Posted 6 min ago"
  2. Random IDs or UUIDs
  3. Animations not completing

Fixes:

Timestamps: Add meticulous-ignore class

<span className="meticulous-ignore">
  Posted {timeAgo} ago
</span>

Random IDs: Use deterministic IDs in tests

const generateId = () => {
  if (window.Meticulous?.isRunningAsTest) {
    return 'test-id-12345'
  }
  return crypto.randomUUID()
}

Animations: Disable in tests

const animationDuration = window.Meticulous?.isRunningAsTest ? 0 : 300

Learn more: Fix False Positive Diffs


Migration from App Router

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

  1. Move recorder: From app/layout.tsx to pages/_document.tsx
  2. Update auth: Change from headers() to getServerSideProps
  3. Test thoroughly: Server-side logic differs between routers

Testing Best Practices

1. Record Real User Sessions

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

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

2. Handle Dynamic Content

For frequently changing content:

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

3. Test Locally

Run tests locally before CI:

# Start your app
npm run dev

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

Advanced Configuration

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

See Also