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
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
PORTenv var (or setcontainer-portto match) - Respond
2xxto a health-check endpoint (defaults toGET /; override withcontainer-health-check-endpoint)
If you don't already have a Dockerfile, the Next.js with Docker example is a good starting point.
3. Add API Token Secret
- Get your API token from Meticulous dashboard → Project Settings
- Go to GitHub repo → Settings → Secrets and variables → Actions
- Create secret named
METICULOUS_API_TOKENwith your token
4. Configure Project Settings
Visit your project settings in Meticulous dashboard:
- Go to Network Stubbing section
- Select: "Stub all requests, apart from requests for server components and static assets"
- 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()ornew 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:
seedrandompackage
How it works:
- Meticulous sets
meticulous-is-test: 1header during tests - If you've configured a simulated date header in your Meticulous project settings (using the Simulated Date template), it provides a deterministic seed
- Calls to your helper return predictable values during tests
- 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:
- Install ModHeader
- 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)
- Visit your page and reload multiple times
- 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:
- Meticulous intercepts requests matching
^/_next/static/ - Files are served from
companion-assets/_next/static/ - 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:
- Not using a simulated date header for server-side rendering
- Using
Date.now()ornew 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:
- Verify folder exists:
ls -la companion-assets/_next/static - Check files were copied: Should see CSS/JS files
- Verify regex pattern matches:
^/_next/static/should match/_next/static/chunks/123.js - Check workflow syntax: Both
companion-assets-folderandcompanion-assets-regexrequired
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:
- Build failed silently
- Port already in use
- Missing environment variables
- 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/"
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:
- Keep recorder in head: Move from
_document.tsxtoapp/layout.tsx - Update network stubbing: Enable Server Component request passthrough
- Update Math.random() calls: Use
createDeterministicRandom()helper in Server Components - Update date handling: Use
getCurrentDate()helper in Server Components - Test thoroughly: Server Components behave differently than client components
Example Repository
See complete working example:
See Also
- Onboarding Guide - General Meticulous setup
- Network Stubbing Explanation - How API mocking works
- Fix False Positives - Handle non-deterministic content
- Troubleshoot Authentication - Auth patterns and solutions
- Companion Assets Guide - Deep dive into static asset optimization