Have you ever wondered if that third-party binary or container image in your production is exactly what the vendor claims it to be? Reproducible builds solve this trust problem by ensuring identical outputs from identical inputs, preventing build-time tampering and cutting CI costs through reliable caching. A reproducible build is one where the same source code, built with the same build environment and build instructions, produces bit-for-bit identical output every time. This enables verification that deployed code matches the source, prevents build-time tampering, and allows aggressive caching in CI pipelines since identical inputs guarantee identical outputs.
At Polar Signals, we take reproducible builds very seriously. We recently implemented reproducible builds for our Next.js applications. This post shares our journey and the practical solutions we developed to achieve reproducible builds with Next.js.
The Challenge: Non-Determinism Next.js Build
Like most modern web frameworks, Next.js has some characteristics that require attention when creating reproducible builds:
- Timestamps: Build artifacts contain timestamps that change with each build
- Random identifiers: Next.js generates random IDs for various internal purposes
- JSON Key ordering: JSON files may have properties in different orders between builds
- Manifest data: The prerender manifest includes Vercel preview-related fields that can vary
These sources of non-determinism mean that building the same source code twice produces different output files, making it impossible to verify build integrity through simple hash comparison.
How to Validate Build Determinism
Before diving into solutions, let's first understand how to check if your builds are deterministic. The process is straightforward:
-
Build your application twice:
pnpm build mv .next .next-build1 pnpm build mv .next .next-build2
-
Compare the builds using diff:
gif diff --no-index -r .next-build1 .next-build2
When you run these commands on a standard Next.js build, you'll see numerous differences:
- Different timestamps on files
- Varying content in JSON files
- Changed identifiers in JavaScript bundles
- Vercel preview metadata in manifests
These differences confirm that these builds are not deterministic by default. Now let's see how to fix this.
Our Solution: A Four-Step Approach
We developed a build script that addresses each source of non-determinism systematically. Here's our run-deterministic-build.sh
:
NODE_ENV=production pnpm build && \
node scripts/remove-preview-field.js && \
pnpm jsonsort ".next/**/*.json" && \
find .next -exec touch -ht 202101010000.00 {} +
Let's break down each step:
Step 1: Production Build
NODE_ENV=production pnpm build
We ensure we're building in production mode to avoid any development-specific variations.
Step 2: Remove Non-Deterministic Preview Field
node scripts/remove-preview-field.js
Next.js includes a preview field in the prerender manifest that contains data that can vary between builds. We created a custom script to remove this field:
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const manifestPath = path.join(
__dirname,
'../path/to/standalone/.next/prerender-manifest.json'
);
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
delete manifest.preview;
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
} catch (error) {
process.exit(1);
}
Step 3: Sort keys in JSON Files
pnpm jsonsort ".next/**/*.json"
JavaScript objects don't guarantee property order, which can lead to JSON files with the same content but different property arrangements. We use jsonsort
to ensure consistent ordering across all JSON files in the build output.
Step 4: Reset Timestamps
find .next -exec touch -ht 202101010000.00 {} +
Finally, we set all file timestamps to a fixed date (January 1, 2021, 00:00:00). This eliminates timestamp-based variations while preserving the ability to use file timestamps for other purposes if needed.
Making Build IDs Deterministic
In addition to the build script, we configured Next.js to use deterministic build IDs based on git commit hashes:
// next.config.js
generateBuildId: async () => {
if (process.env.GIT_HASH != null && process.env.GIT_HASH !== '') {
// GIT_HASH is set by the build system
return process.env.GIT_HASH;
}
// GIT_HASH is not set, so we try to get the current commit hash
const commitId = child_process.execSync('git rev-parse HEAD').toString().trim();
return commitId;
}
This ensures that builds from the same commit always have the same build ID, rather than a randomly generated one.
Bonus: Optimizing for Monorepos
If you're working in a monorepo, here's an additional optimization you might find useful. You can avoid unnecessary rebuilds by only using commits that actually changed your Next.js app:
// next.config.js for a monorepo
generateBuildId: async () => {
// Only use commits that changed the ui directory
const commitId = child_process
.execSync('git rev-list HEAD -- ui | head -1')
.toString()
.trim();
return commitId;
}
This prevents rebuilds when changes occur in other parts of your repository, while still maintaining reproducibility. Even better, your CI can use this same git command to determine whether to skip the build entirely when no UI changes are detected.
With these above steps combined, we transform our Next.js's builds into fully reproducible ones.
Conclusion
Reproducible builds are achievable with Next.js through a straightforward four-step process. The small effort required pays off in enhanced security, verifiable deployments, and more efficient CI pipelines.
If you're implementing reproducible builds in your Next.js projects, we'd love to hear about your experience and any additional techniques you've discovered.