reportgen
Back to all posts

Template Version Control: Update PDFs Without Breaking Production šŸ”„

October 12, 2025

You just fixed a typo in your invoice template.

You deploy it. Everything looks fine.

Then you get 500 Slack messages because last month's invoices no longer render. A customer needs their February report re-generated. It breaks.

Why? Because you updated the template, but old data doesn't match the new structure.

This shouldn't happen.

Template versioning is how you prevent it. Here's how to do it right.


The Problem: Templates Change, Data Doesn't

Your PDF template expects certain data fields. Over time, you:

  • Add new sections
  • Rename variables
  • Change layouts
  • Fix bugs
  • Refactor logic

But your old data doesn't change. It's frozen in time.

The Breaking Scenario

// v1 template (February)
<h1>Invoice for {{customer_name}}</h1>
<p>Amount: {{total}}</p>
 
// v2 template (March) - you renamed fields
<h1>Invoice for {{client.name}}</h1>
<p>Amount: {{billing.total}}</p>
 
// Old data from February
{
  customer_name: "Acme Corp",
  total: 1500
}
 
// āŒ Regenerating February invoice with v2 template = BROKEN
// client.name is undefined
// billing.total is undefined

Your invoices break. Your customers are angry. Your week is ruined.


The Solution: Version Your Templates

Pattern 1: Template Versioning in Storage

Store templates with version numbers.

templates /
		invoice - v1.hbs;
invoice - v2.hbs;
invoice - v3.hbs;

When generating a PDF, always specify the template version.

await generatePDF({
	template: "invoice-v2",
	data: reportData,
	version: 2,
});

Store the version in your database:

// PDFs table
{
  id: 'report-123',
  user_id: 'user-456',
  template_name: 'invoice',
  template_version: 2,
  data: { ... },
  pdf_url: 'https://...',
  created_at: '2025-06-01'
}

Pattern 2: Immutable Template IDs

Instead of names, use unique IDs.

const templates = {
	"invoice-a3f9b2": { version: 1, path: "invoice-v1.hbs" },
	"invoice-d8c4e1": { version: 2, path: "invoice-v2.hbs" },
	"invoice-f1a7b9": { version: 3, path: "invoice-v3.hbs" },
};
 
// Store template ID with each report
await db.reports.insert({
	id: reportId,
	template_id: "invoice-d8c4e1", // Locked to this template forever
	data: reportData,
	created_at: new Date(),
});
 
// Regenerate report with SAME template
async function regenerateReport(reportId) {
	const report = await db.reports.findOne({ id: reportId });
	const template = templates[report.template_id];
 
	return await generatePDF({
		template: template.path,
		data: report.data,
	});
}

Benefits:

  • āœ… Old reports always regenerate correctly
  • āœ… New reports use the latest template
  • āœ… No accidental breakage
  • āœ… Full audit trail

Pattern 3: Data Migrations for Breaking Changes

Sometimes you need to change the data structure.

In that case, migrate old data before regenerating.

const migrations = {
	"invoice-v1-to-v2": (oldData) => {
		return {
			client: {
				name: oldData.customer_name,
				email: oldData.customer_email,
			},
			billing: {
				total: oldData.total,
				currency: oldData.currency || "USD",
			},
		};
	},
 
	"invoice-v2-to-v3": (oldData) => {
		return {
			...oldData,
			billing: {
				...oldData.billing,
				tax_rate: oldData.tax_rate || 0,
				subtotal: oldData.billing.total / (1 + (oldData.tax_rate || 0)),
			},
		};
	},
};
 
async function regenerateReportWithMigration(reportId) {
	const report = await db.reports.findOne({ id: reportId });
 
	let data = report.data;
	let currentVersion = report.template_version;
 
	// Apply migrations sequentially
	while (currentVersion < LATEST_VERSION) {
		const migrationKey = `invoice-v${currentVersion}-to-v${
			currentVersion + 1
		}`;
		const migration = migrations[migrationKey];
 
		if (migration) {
			data = migration(data);
			currentVersion++;
		} else {
			throw new Error(`No migration found: ${migrationKey}`);
		}
	}
 
	return await generatePDF({
		template: `invoice-v${LATEST_VERSION}`,
		data: data,
	});
}

Use this when:

  • You're consolidating templates
  • You're fixing critical bugs in old data
  • You're standardizing data structures

Pattern 4: Feature Flags for Gradual Rollout

Test new templates on a subset of users before going all-in.

async function getTemplateForUser(userId, templateName) {
	const user = await db.users.findOne({ id: userId });
 
	// Beta users get v3
	if (user.beta_tester) {
		return `${templateName}-v3`;
	}
 
	// 10% rollout to everyone else
	if (Math.random() < 0.1) {
		return `${templateName}-v3`;
	}
 
	// Everyone else gets v2
	return `${templateName}-v2`;
}
 
// Usage
app.post("/generate-report", async (req, res) => {
	const { userId, data } = req.body;
 
	const template = await getTemplateForUser(userId, "invoice");
 
	const pdf = await generatePDF({
		template: template,
		data: data,
	});
 
	res.json({ pdf_url: pdf.url });
});

Track rollout metrics:

async function trackTemplateUsage(templateVersion, success, error) {
	await metrics.counter("template.usage", 1, {
		version: templateVersion,
		status: success ? "success" : "failed",
		error_type: error?.type,
	});
}

Pattern 5: Git-Based Template Versioning

Store templates in Git for full version control.

repo/
  templates/
    invoice.hbs
  .git/

Tag each deployment:

git tag template-invoice-v3
git push --tags

Store the Git commit hash with each report:

await db.reports.insert({
	id: reportId,
	template_name: "invoice",
	template_commit: "a3f9b2d8c4e1f7a9", // Git commit hash
	data: reportData,
	created_at: new Date(),
});
 
// Regenerate with exact template version
async function regenerateReport(reportId) {
	const report = await db.reports.findOne({ id: reportId });
 
	// Checkout specific commit
	await exec(`git checkout ${report.template_commit}`);
 
	// Generate PDF
	const pdf = await generatePDF({
		template: `templates/${report.template_name}.hbs`,
		data: report.data,
	});
 
	// Return to main branch
	await exec("git checkout main");
 
	return pdf;
}

Pro: Full version history via Git.

Con: Requires Git operations during PDF generation (slower).


Testing Template Changes

1. Snapshot Testing

Render old reports with new templates and diff the output.

import { diffPDFs } from "./test-utils";
 
describe("Invoice Template v3", () => {
	it("should render old data correctly", async () => {
		const oldReport = await db.reports.findOne({
			template_version: 2,
			created_at: { $lt: new Date("2025-05-01") },
		});
 
		// Render with new template
		const newPDF = await generatePDF({
			template: "invoice-v3",
			data: oldReport.data,
		});
 
		// Render with old template (expected output)
		const oldPDF = await generatePDF({
			template: "invoice-v2",
			data: oldReport.data,
		});
 
		// Visual diff
		const diff = await diffPDFs(oldPDF, newPDF);
 
		expect(diff.percentChanged).toBeLessThan(5); // Allow 5% difference
	});
});

2. Regression Testing with Real Data

async function runRegressionTest(templateName, newVersion) {
	// Get 100 random reports from last 3 months
	const reports = await db.reports.aggregate([
		{
			$match: {
				template_name: templateName,
				created_at: {
					$gt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
				},
			},
		},
		{ $sample: { size: 100 } },
	]);
 
	let failed = 0;
 
	for (const report of reports) {
		try {
			await generatePDF({
				template: `${templateName}-v${newVersion}`,
				data: report.data,
			});
		} catch (err) {
			console.error(`Failed to render report ${report.id}:`, err);
			failed++;
		}
	}
 
	const successRate = ((reports.length - failed) / reports.length) * 100;
 
	console.log(`Regression test: ${successRate.toFixed(2)}% success rate`);
 
	if (successRate < 99) {
		throw new Error(`Template v${newVersion} failed regression test`);
	}
}
 
// Run before deploying
await runRegressionTest("invoice", 3);

Handling Template Deprecation

Eventually, you'll want to remove old templates.

1. Track Usage

async function checkTemplateUsage(templateName, version) {
	const count = await db.reports.countDocuments({
		template_name: templateName,
		template_version: version,
		created_at: { $gt: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000) },
	});
 
	console.log(`${templateName}-v${version}: ${count} reports in last year`);
	return count;
}
 
// Check before deprecating
const usage = await checkTemplateUsage("invoice", 1);
 
if (usage === 0) {
	console.log("Safe to remove invoice-v1");
} else {
	console.log(`Still used by ${usage} reports - DO NOT REMOVE`);
}

2. Automatic Migration

Migrate old reports to new template versions.

async function migrateOldReports(templateName, oldVersion, newVersion) {
	const oldReports = await db.reports.find({
		template_name: templateName,
		template_version: oldVersion,
	});
 
	console.log(
		`Migrating ${oldReports.length} reports from v${oldVersion} to v${newVersion}`,
	);
 
	for (const report of oldReports) {
		try {
			// Apply migration
			const migratedData = migrations
				[`${templateName}-v${oldVersion}-to-v${newVersion}`](
					report.data,
				);
 
			// Update report
			await db.reports.update(
				{ id: report.id },
				{
					template_version: newVersion,
					data: migratedData,
					migrated_at: new Date(),
				},
			);
 
			console.log(`Migrated report ${report.id}`);
		} catch (err) {
			console.error(`Failed to migrate report ${report.id}:`, err);
		}
	}
}
 
// Run migration
await migrateOldReports("invoice", 1, 3);

3. Deprecation Timeline

const templateDeprecation = {
	"invoice-v1": {
		deprecated_at: new Date("2025-03-01"),
		removal_date: new Date("2025-09-01"),
		replacement: "invoice-v3",
	},
};
 
async function checkDeprecation(templateId) {
	const deprecation = templateDeprecation[templateId];
 
	if (!deprecation) return;
 
	const daysUntilRemoval = Math.ceil(
		(deprecation.removal_date - new Date()) / (1000 * 60 * 60 * 24),
	);
 
	if (daysUntilRemoval <= 30) {
		await alerting.notify({
			severity: "warning",
			message:
				`Template ${templateId} will be removed in ${daysUntilRemoval} days`,
			replacement: deprecation.replacement,
		});
	}
}

Production-Ready Template Versioning System

import express from "express";
import { db } from "./database";
 
const app = express();
 
// Template registry
const templates = {
	"invoice": {
		latest: 3,
		versions: {
			1: { path: "invoice-v1.hbs", deprecated: true },
			2: { path: "invoice-v2.hbs", deprecated: false },
			3: { path: "invoice-v3.hbs", deprecated: false },
		},
	},
};
 
// Generate PDF with versioning
app.post("/generate-report", async (req, res) => {
	try {
		const { templateName, data, userId } = req.body;
 
		// Get latest template version
		const template = templates[templateName];
		const version = template.latest;
		const templatePath = template.versions[version].path;
 
		// Generate PDF
		const response = await fetch(
			"https://reportgen.io/api/v1/generate-pdf",
			{
				method: "POST",
				headers: {
					"Content-Type": "application/json",
					"X-API-Key": process.env.REPORTGEN_API_KEY,
				},
				body: JSON.stringify({
					html_template: await readTemplate(templatePath),
					data: data,
					engine: "handlebars",
				}),
			},
		);
 
		const result = await response.json();
 
		// Store report with version info
		await db.reports.insert({
			id: result.report_id,
			user_id: userId,
			template_name: templateName,
			template_version: version,
			data: data,
			pdf_url: result.pdf_url,
			created_at: new Date(),
		});
 
		res.json({
			report_id: result.report_id,
			pdf_url: result.pdf_url,
			template_version: version,
		});
	} catch (err) {
		res.status(500).json({ error: err.message });
	}
});
 
// Regenerate old report
app.post("/regenerate-report/:reportId", async (req, res) => {
	try {
		const { reportId } = req.params;
		const report = await db.reports.findOne({ id: reportId });
 
		if (!report) {
			return res.status(404).json({ error: "Report not found" });
		}
 
		// Use ORIGINAL template version
		const template = templates[report.template_name];
		const templateInfo = template.versions[report.template_version];
 
		if (templateInfo.deprecated) {
			console.warn(
				`Regenerating with deprecated template: ${report.template_name}-v${report.template_version}`,
			);
		}
 
		// Generate PDF
		const response = await fetch(
			"https://reportgen.io/api/v1/generate-pdf",
			{
				method: "POST",
				headers: {
					"Content-Type": "application/json",
					"X-API-Key": process.env.REPORTGEN_API_KEY,
				},
				body: JSON.stringify({
					html_template: await readTemplate(templateInfo.path),
					data: report.data,
					engine: "handlebars",
				}),
			},
		);
 
		const result = await response.json();
 
		res.json({
			report_id: reportId,
			pdf_url: result.pdf_url,
			template_version: report.template_version,
		});
	} catch (err) {
		res.status(500).json({ error: err.message });
	}
});
 
app.listen(3000);

Key Takeaways

Template versioning prevents production disasters.

Here's what to do:

  • āœ… Store template version with every report
  • āœ… Lock reports to specific template versions
  • āœ… Test new templates against old data
  • āœ… Use feature flags for gradual rollouts
  • āœ… Build data migrations for breaking changes
  • āœ… Track template usage before deprecating
  • āœ… Keep old templates around longer than you think

Don't break production. Version your templates.

Ready to deploy template changes with confidence? Try reportgen.io - built for production workflows.