How we generated 300 template pages without making doorway pages
Our site has a quote template for 100 trades in English, Chinese and Japanese: 300 pages and 600 Excel and Word files, all generated from one typed data set. How we kept them from being thin near-duplicates, how we measured that, why the page JavaScript fell from 221 KB to 123 KB gzip, and the iPhone preview that showed blank amounts in our spreadsheets.
Our marketing site has a quote template for each of 100 trades, from air-conditioning installers to boxed-lunch caterers, in English, Traditional Chinese and Japanese. That is 300 pages, and each one offers the same quote as a free Excel file and a free Word file: 600 downloads. All of it is generated from one typed data set.
"Generated pages for 100 keywords" is also a fair description of a doorway site. Google's spam policies name doorway pages explicitly: pages made to rank for many similar queries that are substantially similar to each other and less useful than they look. So the rule we set before writing any code was that a page only exists if it says something about its trade, in its market, that no other page says. This post is about how we tried to hold that rule at scale and how we checked it. The numbers are measured, and the code is what runs on main today, trimmed.
The shape of it
src/lib/quote-templates/*.ts- /templates/quote/<slug>, /zh/…, /ja/…
- title and description patterns with length budgets
- canonical, hreflang, sitemap
- live formulas with cached results
- 10% / 8% tax by row (ja)
- byte-for-byte reproducible
- /tools/quote?preset=<slug>
- same slugs, same line items
One set of TypeScript files feeds three things: the pages, the downloadable files, and the presets of the free quote tool in our app (/tools/quote?preset=<slug>), which shares the same slugs and the same line items. Apart from the page's frame (section headings, the download block, the call to action) and the title pattern, every sentence on a template page comes from that trade's own record.
One typed record per trade, written three times
Each trade is a QuoteTemplate. The important part is that en, zh and ja are three separately written QtCopy objects, not one text and two translations:
// src/lib/quote-templates/types.ts (trimmed)
export interface QtCopy {
name: string; // the trade as people search for it
desc: string; // meta description
intro: string; // who sends this quote and what makes it different
items: QtItem[]; // [item, unit, qty, example price]
include: string[]; // what a good quote for this trade spells out
mistakes: string[]; // mistakes that cost money or the job
terms: string; // deposit, balance, validity, changes
faq: { q: string; a: string }[];
}
export interface QuoteTemplate {
slug: string;
category: QtCategory;
downloadDepositPercent?: number; // when the deposit applies only to some jobs
jaReducedRateRows?: number[]; // rows taxed at 8% in the Japanese files
en: QtCopy;
zh: QtCopy;
ja: QtCopy;
}
The boxed-lunch trade shows why translation would not work. The US page quotes 40 boxes with drinks on their own line and sales tax added on the invoice. The Taiwanese page quotes 50 boxes, asks whether the customer needs a 統編 (company tax ID) on the invoice, and warns that leaving 含稅 or 未稅 off the quote costs you 5%. The Japanese page splits the quote by consumption-tax rate, because boxed meals and tea are taxed at the reduced 8% and delivery at 10%, and asks for the invoice registration number:
// src/lib/quote-templates/occasions.ts (trimmed)
slug: 'bento',
downloadDepositPercent: 0,
jaReducedRateRows: [1, 2, 3, 6],
// …
ja: {
name: '弁当・仕出し',
items: [
['会議用弁当(幕の内)', '個', '30', '1千〜2千5百円/個'],
['ベジタリアン・アレルギー対応弁当', '個', '4', '1千2百〜3千円/個'],
['お茶(ペットボトル 500ml)', '本', '34', '120〜200円/本'],
['配達料(最低注文数未満・エリア外)', '回', '1', '500〜3,000円'],
// …
Each new trade has six line items with example prices in that market's currency, what to include, common mistakes, its payment terms and three or four FAQs. Rows stay parallel across the three languages, so the same row can become a preset in the quote tool.
We went from 51 trades to 100 in one step, and the list of trades we did not add was as deliberate as the list we did. We left out:
- trades that overlapped a page we already had;
- licensed or regulated work, where a quote reads like professional advice;
- trades with thin search demand in at least two of the three markets;
- trades that only exist in Japan and would have no natural English or Chinese page.
Measuring "too similar"
"Every page is different" is easy to claim and hard to check by reading 300 pages. We wanted a number we could re-run on every change.
The measure is simple: take the rendered <main> text of each page from a local production build, remove all whitespace, cut it into overlapping 8-character pieces (shingles), and compute the Jaccard similarity of two pages' shingle sets: shared pieces divided by all distinct pieces. Character shingles need no tokenizer, so the same code works for English, Chinese and Japanese. For each page we take its nearest neighbour, the most similar other template page in the same language.
Three things stand out.
The new pages are less alike than the old ones. Nearest-neighbour similarity averages 0.254 for the 49 new English pages against 0.319 for the 51 existing ones; 0.258 against 0.304 in Chinese; 0.266 against 0.327 in Japanese. The highest value for any new page is 0.290 (English), below the existing pages' 0.367. The new pages are also longer: 2,822 characters of main text on average in English against 2,109, 1,190 against 964 in Chinese, 1,464 against 1,164 in Japanese.
Most of what pages share is the frame. On each trade's own copy only (intro, items, what to include, mistakes, terms, FAQ), the average pair scores 0.001 to 0.009 depending on the language, and the closest pair of any two trades is 0.061. The 0.2 to 0.3 at page level is headings, the download block and the call to action. One giveaway: in Chinese, the nearest neighbour of the wallpaper, flyer-distribution and junk-removal pages is the nail-salon page. They share a layout, not a subject.
It is a regression check, not a verdict. Google does not publish a similarity threshold, and a low Jaccard score does not make a page useful. What the number does is catch the failure we were afraid of: a batch of pages written from one mould, where only the trade name changes. If the new pages had come out more alike than the old ones, we would have rewritten them before shipping.
Spreadsheets that show their numbers before anyone opens Excel
The Excel files are not pictures of a quote. Quantity × unit price is a live formula on every line, and subtotal, tax, total, deposit and balance follow. The first version of the generator wrote only the formulas, and Excel calculated them on open, so it looked fine in Excel.
But many people never open the file in Excel. They tap it in Mail, Files or LINE on an iPhone, or preview it with Quick Look on a Mac, and those viewers do not calculate: they show what is stored in the file. In all of them every amount, the subtotal and the total were blank. The template looked broken in exactly the place people look first. Review caught it before the files shipped.
The fix is to write every formula together with its result, and to tell Excel to recalculate anyway when the file is opened:
// scripts/build-template-downloads.ts (trimmed)
// Every formula also carries its result, so previews that do not calculate
// (iOS Files, Mail, LINE, Quick Look) still show the amounts; Excel recalculates on open.
wb.calcProperties.fullCalcOnLoad = true;
// …
values.amount = { formula: `IF(OR(${Q}${r}="",${P}${r}=""),"",${Q}${r}*${P}${r})`, result: amount };
That means the generator computes every number itself, the same way the formula will: per line, then per tax rate, with each market's rounding. Across the 302 workbooks there are 7,790 formula cells, and a test fails if any of them is stored without a value.
The Japanese files needed one more step. Japan's consumption tax is 10%, or 8% for food and drink sold to take away or delivered, and an invoice has to show the total and the tax for each rate. Each Japanese line therefore has a rate cell, and the sheet sums and rounds down per rate:
const b10 = addSum(j.base10, { formula: `SUMIF(${rates},0.1,${range})`, result: base['10'] }, copy.currency);
const t10 = addSum(j.tax10, { formula: round(`${b10}*0.1`), result: tax10 }, copy.currency);
const b8 = addSum(j.base8, { formula: `SUMIF(${rates},0.08,${range})`, result: base['8'] }, copy.currency);
const t8 = addSum(j.tax8, { formula: round(`${b8}*0.08`), result: tax8 }, copy.currency);
For the boxed-lunch sheet, ¥40,880 of food and tea at 8% and ¥500 of delivery at 10% come to a total of ¥44,700. With every line at 10% it would have said ¥45,518, a quote a careful customer would send back.
Two smaller decisions came from reading the trades' own terms. The deposit rate in each file starts at the percentage the page's terms name next to a deposit. But for 19 trades the terms take a deposit only on some jobs (ordered parts, first orders, larger jobs), so those files start at 0% through downloadDepositPercent, and every file says the rate can be changed. The Word files use fixed column widths in twips rather than percentages, because Google Docs' import collapses percentage widths.
Generated once, byte for byte
The 602 files (600 quotes plus two income-expense sheets, 7.15 MB) are generated by a script at commit time and committed, not built during pnpm build. That keeps the spreadsheet and Word libraries out of the production image, and a page can never link a file that failed to generate.
Committing generated binaries only works if regenerating them changes nothing. A zip records file dates, and the Word library gives hyperlinks random relationship ids, so we re-zip every file with fixed dates and numbered ids:
/** Re-zip with fixed entry dates and document dates so the output is reproducible. */
async function normalize(buf: Buffer | ArrayBuffer | Uint8Array): Promise<Buffer> {
// …sort the entries, replace random rIds with rIdLink1, rIdLink2…, fix docProps dates…
out.file(n, data, { date: FIXED_DATE, createFolders: false });
// …
}
Re-running the generator takes about 8 seconds here and leaves git clean, so a diff in static/templates/files/ always means the data changed. A test fails if a file no longer matches its trade's data.
The JavaScript nobody asked for
Adding 49 trades almost added something we did not intend. The template page needed a few UI strings and two link helpers, and it imported them from the module's index:
// before
import { UI, signupHref, toolHref } from '$lib/quote-templates';
// after
// From ui.ts, not the index: the index pulls every trade's copy into the page's JavaScript.
import { UI, signupHref, toolHref } from '$lib/quote-templates/ui';
The index also exports the array of every trade, so the bundler put every trade's text in all three languages into the page's client JavaScript, even though the page gets its own trade's copy from the server. With 51 trades that was already 98 KB gzip. With 100 it would have been 249 KB.
We measured all three states on local production builds: the page before the expansion, the same page with 100 trades and the old import (built only to measure), and what shipped. The English page went from 221.3 KB gzip to 123.5 KB, and none of it is trade copy. The same reasoning is why the trade count shown on the hub (100) is a constant with a test that compares it to the data, instead of QUOTE_TEMPLATES.length: the site dictionaries that print the count would otherwise import every trade too.
Titles, hreflang and the sitemap
Titles follow how people search in each market, and they have budgets. In Japanese, search results cut a title at about 32 characters, so the title drops "Word", then "Excel", before it would drop the trade name:
// src/lib/quote-templates/ui.ts
const JA_TITLE_SUFFIXES = ['(無料・Excel・Word)', '(無料・Excel)', '(無料)'];
// …
const h1 = `${t.ja.name}の見積書テンプレート`;
const suffix = JA_TITLE_SUFFIXES.find((s) => h1.length + s.length <= TITLE_MAX) ?? '(無料)';
return { h1, title: `${h1}${suffix}|NEXUPIRA` };
Fifteen of the 100 Japanese titles drop "Word" this way. Chinese and Japanese descriptions keep each trade's own summary first and then end with the same promise, a free Excel and Word download without sign-up; the longest is 93 characters in Chinese and 107 in Japanese, under a budget of 110. Tests check every title and description against these limits.
Each page has a self-referencing canonical and hreflang links to its three language versions (en, zh-Hant, ja, with English as x-default), and the sitemap carries the same alternates with a per-trade last-modified date: 300 entries. One constraint came from production routing: our edge only sends certain path prefixes to the marketing site, and everything else goes to the app. English pages have no language prefix, so every English URL has to live under one of those prefixes, here /templates. A test fails if a new English URL does not.
Internal links matter for 100 pages too. Each page links four related trades: its own related list first, then the trades that follow it in its category, wrapping round. Before, every page in a category linked the same first four. A test checks that every trade is linked from at least one other trade page.
What we know so far
Not much yet, and we would rather say so. Before this expansion, spot checks of Japanese search results put some of our niche trade pages high quickly: #1 for OEM 見積書 テンプレート, #4 for 看板 (signage) and #4 for 自動車整備 (car servicing). The search term 見積書テンプレート itself belongs to large accounting and office-software vendors, which is why we went wide on specific trades instead. Those were early observations on a handful of the original 51 pages. The 49 new trades went live the day this post was written, so there is no search data for them yet.
Limits
- Prices are illustrative. They are labelled as examples on every page and in every file; they are ranges we wrote, not market research.
- We have not opened the files in desktop Excel or Word. They were checked with LibreOffice and macOS Quick Look, and by tests that read every workbook.
- Similarity is our proxy, not Google's. It catches pages cast from one mould; it says nothing about whether a page helps.
- No structured data for the downloads. Google has no rich result for a downloadable document, so we did not add markup that would be ignored.
If you are generating pages from data
- Write a rule for when a page may exist before you write the generator, and make it about content the page alone has.
- Keep the data per market, not per string. If two languages can share one sentence, they probably should not share one page.
- Measure similarity on the text people see, and separately on the content alone. The difference between the two is your frame.
- Open your generated files where your users open them. A phone preview does not run formulas.
- Check what your page imports. One import from an index file put 249 KB gzip of other pages' text on every page.
NEXUPIRA builds AI coworkers for small businesses: when a customer asks for a price on LINE, it drafts the reply and the quote, and it keeps the books and the calendar. The templates in this post are free on our quote template page.