Complete guide for setting up Meticulous with Next.js applications using the App Router (Next.js 13+).
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
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.
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/"
- 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
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.
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)
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.
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
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.tssupport) seedrandompackage
How it works:
- Meticulous sets
meticulous-is-test: 1header during tests meticulous-simulated-dateprovides deterministic seed- Math.random() returns predictable values during tests
- Production behavior unchanged
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")
Use a browser extension to simulate Meticulous headers:
- Install ModHeader
- Add headers:
meticulous-is-test:1meticulous-simulated-date:Fri, 17 May 2024 13:35:20 GMT
- Visit your page and reload multiple times
- Content should be identical on each reload
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
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
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>
)
}
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>
}
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>
}
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()
}
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()
}
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
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
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 }}
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)
Symptom: Diffs showing "Posted 5 min ago" vs "Posted 6 min ago"
Causes:
- Not using
meticulous-simulated-dateheader - Using
Date.now()ornew Date()in Server Components
Fix: Use getCurrentDate() helper (see Handling Timestamps section)
Symptom: Random UUIDs, shuffled arrays, or randomized content causes diffs
Fix: Install seedrandom and setup instrumentation.ts (see Handling Math.random() section)
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
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
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
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/"
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/"
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>
)
}
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
For content that changes frequently (ads, recommendations, live data):
<div className="meticulous-ignore">
{/* Content that changes frequently */}
</div>
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"
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
- Add instrumentation.ts: For Math.random() if used in Server Components
- Update date handling: Use
getCurrentDate()helper in Server Components - Test thoroughly: Server Components behave differently than client components
See complete working example:
- 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