Localized SEO Metadata Generator (English) | Intermediate Guide
This tutorial walks you through building a Localized SEO Metadata Generator in English that can produce SEO titles, meta descriptions, and keyword ideas tailored to different locales (e.g., en-US, en-GB, en-AU) while keeping messaging consistent and compliant with best practices. You’ll implement a small command-line tool, add locale-aware rules, validate output lengths, and optionally integrate with an LLM API.
You’ll end with a working project that can:
- Read product/page data from JSON/CSV
- Generate localized SEO metadata in English variants
- Enforce constraints (title length, description length, forbidden terms)
- Export results for CMS upload (CSV/JSON)
- Optionally use an LLM for creative copy while keeping deterministic guardrails
Table of Contents
- What “Localized SEO Metadata” Means
- SEO Constraints You Must Enforce
- Project Setup
- Data Model: Input and Output
- Locale Rules: en-US vs en-GB vs en-AU
- Implementation: Generator Core
- Length Checking and Snippet Preview
- Keyword Expansion (Deterministic)
- CLI: Generate Metadata for Many Pages
- Export to CSV and JSON
- Optional: LLM-Assisted Copy with Guardrails
- Testing Your Generator
- Operational Tips and Common Pitfalls
- Next Steps
What “Localized SEO Metadata” Means
SEO metadata usually refers to:
<title>(page title): what appears as the clickable headline in search results.<meta name="description">: the snippet text search engines may display.- Keywords: not a direct ranking factor via a meta tag in modern Google, but still useful for internal planning, content briefs, and consistency.
Localization is not only translation. Even within English, different locales have:
- Spelling: “color” (US) vs “colour” (UK/AU)
- Terminology: “apartment” (US) vs “flat” (UK); “cell phone” vs “mobile”
- Units and formatting: “$” vs “£”, date formats, punctuation norms
- Search intent differences: users in different markets may search differently
A localized SEO metadata generator should:
- Keep brand voice consistent.
- Apply locale-specific spelling/terms.
- Include location cues when appropriate (without keyword stuffing).
- Respect snippet length and avoid truncation.
- Produce output that’s repeatable and auditable.
SEO Constraints You Must Enforce
Before coding, define constraints. These aren’t “laws,” but they prevent common failures.
Title tag constraints
- Aim for 50–60 characters (not a strict rule; SERPs vary).
- Include the primary keyword early.
- Add a brand suffix when useful:
| BrandName - Avoid duplication across pages.
Meta description constraints
- Aim for 140–160 characters (mobile often truncates earlier).
- Include a value proposition and a CTA (call-to-action).
- Avoid stuffing: don’t repeat the same phrase excessively.
- Ensure it matches on-page content (avoid misleading promises).
Safety and compliance constraints
You may need:
- A forbidden words list (e.g., “guaranteed,” “best,” “#1”) depending on industry.
- A “must include” list (e.g., brand name, regulated disclaimers).
We’ll implement:
- Length validation
- Locale spelling substitutions
- Simple keyword expansion
- Optional LLM generation with post-validation
Project Setup
We’ll build a Node.js-based CLI tool.
Prerequisites
- Node.js 18+ recommended
- npm 9+ (or pnpm/yarn if you prefer)
Verify:
node -v
npm -v
Initialize the project
mkdir localized-seo-metadata-generator
cd localized-seo-metadata-generator
npm init -y
Install dependencies:
npm install commander zod csv-stringify
npm install -D typescript ts-node @types/node
- commander: CLI argument parsing
- zod: schema validation for input/output
- csv-stringify: export CSV
- typescript/ts-node: run TS directly
Create a TypeScript config:
npx tsc --init --rootDir src --outDir dist --esModuleInterop --resolveJsonModule --module ES2022 --target ES2022
Create folders:
mkdir -p src data out
Data Model: Input and Output
Define an input file describing pages/products. Create data/pages.json:
[
{
"id": "running-shoes",
"type": "product",
"brand": "NorthPeak",
"name": "TrailRunner 2",
"category": "Running Shoes",
"primaryKeyword": "trail running shoes",
"secondaryKeywords": ["lightweight", "grip", "water-resistant"],
"locationHint": "Seattle",
"features": ["All-terrain grip", "Breathable mesh", "Reinforced toe cap"],
"audience": "beginner to intermediate runners"
},
{
"id": "accounting-services",
"type": "service",
"brand": "LedgerLine",
"name": "Small Business Accounting",
"category": "Accounting Services",
"primaryKeyword": "small business accountant",
"secondaryKeywords": ["tax filing", "bookkeeping", "payroll"],
"locationHint": "Austin",
"features": ["Monthly reporting", "Dedicated advisor", "Cloud bookkeeping"],
"audience": "small business owners"
}
]
Now define the output fields we want:
locale(e.g.,en-US)titledescriptionkeywords(array)warnings(array of validation warnings)
Locale Rules: en-US vs en-GB vs en-AU
Even in English, you’ll want a small rule engine.
Create src/locales.ts:
export type Locale = "en-US" | "en-GB" | "en-AU";
export type LocaleRules = {
locale: Locale;
spellingMap: Record<string, string>;
currencySymbol?: string;
preferredTerms: Record<string, string>;
};
export const LOCALE_RULES: Record<Locale, LocaleRules> = {
"en-US": {
locale: "en-US",
spellingMap: {
colour: "color",
organise: "organize",
favourite: "favorite"
},
preferredTerms: {
mobile: "cell phone"
}
},
"en-GB": {
locale: "en-GB",
spellingMap: {
color: "colour",
organize: "organise",
favorite: "favourite"
},
preferredTerms: {
apartment: "flat"
}
},
"en-AU": {
locale: "en-AU",
spellingMap: {
color: "colour",
organize: "organise",
favorite: "favourite"
},
preferredTerms: {
apartment: "unit"
}
}
};
How these rules are applied
- spellingMap: post-process generated text to match locale spelling.
- preferredTerms: replace certain words to match local usage.
- You can extend this with measurement units, punctuation, or regulatory disclaimers.
Implementation: Generator Core
Create src/schema.ts to validate input:
import { z } from "zod";
export const PageInputSchema = z.object({
id: z.string().min(1),
type: z.enum(["product", "service", "category", "blog"]),
brand: z.string().min(1),
name: z.string().min(1),
category: z.string().min(1),
primaryKeyword: z.string().min(1),
secondaryKeywords: z.array(z.string()).default([]),
locationHint: z.string().optional(),
features: z.array(z.string()).default([]),
audience: z.string().optional()
});
export type PageInput = z.infer<typeof PageInputSchema>;
export const PagesFileSchema = z.array(PageInputSchema);
Now create src/text.ts for transformations:
import { LocaleRules } from "./locales.js";
export function applyLocaleRules(text: string, rules: LocaleRules): string {
let out = text;
// Replace preferred terms first (word boundary-ish)
for (const [from, to] of Object.entries(rules.preferredTerms)) {
const re = new RegExp(`\\b${escapeRegExp(from)}\\b`, "gi");
out = out.replace(re, match => matchCase(match, to));
}
// Then spelling map
for (const [from, to] of Object.entries(rules.spellingMap)) {
const re = new RegExp(`\\b${escapeRegExp(from)}\\b`, "gi");
out = out.replace(re, match => matchCase(match, to));
}
// Normalize spaces
out = out.replace(/\s+/g, " ").trim();
return out;
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function matchCase(source: string, target: string): string {
if (source.toUpperCase() === source) return target.toUpperCase();
if (source[0]?.toUpperCase() === source[0]) {
return target[0].toUpperCase() + target.slice(1);
}
return target.toLowerCase();
}
Title and description templates
Create src/generator.ts:
import { PageInput } from "./schema.js";
import { Locale, LOCALE_RULES } from "./locales.js";
import { applyLocaleRules } from "./text.js";
export type GeneratedMeta = {
id: string;
locale: Locale;
title: string;
description: string;
keywords: string[];
warnings: string[];
};
type GenerateOptions = {
locale: Locale;
brandSuffix?: boolean;
includeLocation?: boolean;
};
export function generateMetadata(page: PageInput, opts: GenerateOptions): GeneratedMeta {
const rules = LOCALE_RULES[opts.locale];
const warnings: string[] = [];
const includeLocation = opts.includeLocation && !!page.locationHint;
// Build a title with a predictable structure:
// Primary Keyword + Benefit + Brand
const benefit = pickBenefit(page);
const locationPart = includeLocation ? ` in ${page.locationHint}` : "";
const brandPart = opts.brandSuffix ? ` | ${page.brand}` : "";
let title = `${capitalize(page.primaryKeyword)}${locationPart} - ${benefit}${brandPart}`;
// Meta description: value prop + features + CTA
const featurePhrase = page.features.slice(0, 2).join(", ");
const audiencePhrase = page.audience ? ` Ideal for ${page.audience}.` : "";
let description =
`Shop ${page.name} (${page.category}) with ${featurePhrase}.` +
audiencePhrase +
` Explore options and pricing today.`;
// Apply locale rules (spelling/terms)
title = applyLocaleRules(title, rules);
description = applyLocaleRules(description, rules);
const keywords = buildKeywords(page, opts.locale);
// Validate and add warnings
warnings.push(...validateLengths(title, description));
warnings.push(...validateForbidden(description));
return {
id: page.id,
locale: opts.locale,
title,
description,
keywords,
warnings
};
}
function pickBenefit(page: PageInput): string {
// Simple deterministic benefit selection
if (page.type === "product") return "Durable Comfort & Grip";
if (page.type === "service") return "Trusted Local Experts";
if (page.type === "category") return "Top Picks & Deals";
return "Practical Tips & Guides";
}
function buildKeywords(page: PageInput, locale: Locale): string[] {
const base = [
page.primaryKeyword,
...page.secondaryKeywords,
page.category,
page.name
];
// Add locale-specific variants (simple examples)
const localeAdditions: Record<Locale, string[]> = {
"en-US": ["near me", "best price"],
"en-GB": ["near me", "best value"],
"en-AU": ["near me", "top deals"]
};
const all = [...base, ...localeAdditions[locale]]
.map(k => k.trim())
.filter(Boolean);
// Deduplicate case-insensitively
const seen = new Set<string>();
const out: string[] = [];
for (const k of all) {
const key = k.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
out.push(k);
}
}
return out;
}
function validateLengths(title: string, description: string): string[] {
const warnings: string[] = [];
if (title.length < 30) warnings.push(`Title is short (${title.length} chars).`);
if (title.length > 60) warnings.push(`Title may truncate (${title.length} chars).`);
if (description.length < 120) warnings.push(`Description is short (${description.length} chars).`);
if (description.length > 160) warnings.push(`Description may truncate (${description.length} chars).`);
return warnings;
}
function validateForbidden(text: string): string[] {
const forbidden = ["guaranteed", "no. 1", "#1"];
const warnings: string[] = [];
for (const term of forbidden) {
if (text.toLowerCase().includes(term)) warnings.push(`Contains forbidden term: "${term}"`);
}
return warnings;
}
function capitalize(s: string): string {
return s.length ? s[0].toUpperCase() + s.slice(1) : s;
}
This generator is intentionally deterministic: same input → same output. That’s valuable for auditing and bulk operations.
Length Checking and Snippet Preview
Character counts are a proxy for truncation, but SERPs use pixel width. Still, character-based checks catch many issues.
Add a quick “snippet preview” helper so humans can review.
Create src/preview.ts:
export function renderSnippetPreview(title: string, description: string, url: string): string {
const t = clamp(title, 70);
const d = clamp(description, 180);
return [
t,
url,
d
].join("\n");
}
function clamp(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max - 1).trimEnd() + "…";
}
Keyword Expansion (Deterministic)
A common need: expand keyword ideas without calling external APIs. You can do light expansion using:
- Modifiers: “buy,” “pricing,” “reviews,” “near me”
- Location hints: city/region
- Intent variants: “best,” “top,” “affordable” (careful with compliance)
Add src/keywords.ts:
import { PageInput } from "./schema.js";
export function expandKeywords(page: PageInput): string[] {
const base = page.primaryKeyword;
const loc = page.locationHint;
const modifiers = ["pricing", "reviews", "deals", "online", "near me"];
const expanded: string[] = [];
for (const m of modifiers) {
expanded.push(`${base} ${m}`);
}
if (loc) {
expanded.push(`${base} ${loc}`);
expanded.push(`${base} in ${loc}`);
}
// Add feature-based long tails
for (const f of page.features.slice(0, 3)) {
expanded.push(`${base} ${f.toLowerCase()}`);
}
return dedupe(expanded);
}
function dedupe(arr: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const a of arr) {
const k = a.toLowerCase();
if (!seen.has(k)) {
seen.add(k);
out.push(a);
}
}
return out;
}
You can merge these expanded keywords into the generator output if desired, but keep an eye on noise. Keyword lists should be useful, not just long.
CLI: Generate Metadata for Many Pages
Now build the CLI entrypoint.
Create src/cli.ts:
import { Command } from "commander";
import { readFile } from "node:fs/promises";
import { PagesFileSchema } from "./schema.js";
import { generateMetadata } from "./generator.js";
import { renderSnippetPreview } from "./preview.js";
import { writeOutputs } from "./export.js";
const program = new Command();
program
.name("seo-meta")
.description("Localized SEO metadata generator (English locales)")
.option("-i, --input <path>", "Input JSON file", "data/pages.json")
.option("-l, --locales <list>", "Comma-separated locales", "en-US,en-GB")
.option("--no-brandSuffix", "Disable brand suffix in title")
.option("--includeLocation", "Include location hint when available", false)
.option("-o, --outDir <path>", "Output directory", "out")
.option("--preview", "Print snippet previews to stdout", false);
program.parse(process.argv);
const opts = program.opts();
const inputPath: string = opts.input;
const locales: string[] = String(opts.locales).split(",").map((s: string) => s.trim()).filter(Boolean);
const supported = new Set(["en-US", "en-GB", "en-AU"]);
for (const loc of locales) {
if (!supported.has(loc)) {
console.error(`Unsupported locale: ${loc}. Supported: en-US,en-GB,en-AU`);
process.exit(1);
}
}
const raw = await readFile(inputPath, "utf-8");
const parsed = PagesFileSchema.safeParse(JSON.parse(raw));
if (!parsed.success) {
console.error(parsed.error.message);
process.exit(1);
}
const pages = parsed.data;
const results = [];
for (const page of pages) {
for (const locale of locales as any) {
const meta = generateMetadata(page, {
locale,
brandSuffix: opts.brandSuffix,
includeLocation: opts.includeLocation
});
results.push(meta);
if (opts.preview) {
const url = `https://example.com/${page.id}?lang=${locale}`;
console.log("-----");
console.log(renderSnippetPreview(meta.title, meta.description, url));
if (meta.warnings.length) console.log("Warnings:", meta.warnings.join(" | "));
}
}
}
await writeOutputs(results, opts.outDir);
console.log(`Wrote ${results.length} records to ${opts.outDir}`);
Add a src/export.ts to write JSON and CSV.
Export to CSV and JSON
Create src/export.ts:
import { mkdir, writeFile } from "node:fs/promises";
import { stringify } from "csv-stringify/sync";
import { GeneratedMeta } from "./generator.js";
export async function writeOutputs(records: GeneratedMeta[], outDir: string): Promise<void> {
await mkdir(outDir, { recursive: true });
// JSON
await writeFile(`${outDir}/metadata.json`, JSON.stringify(records, null, 2), "utf-8");
// CSV: flatten keywords and warnings
const csv = stringify(
records.map(r => ({
id: r.id,
locale: r.locale,
title: r.title,
description: r.description,
keywords: r.keywords.join("; "),
warnings: r.warnings.join("; ")
})),
{ header: true }
);
await writeFile(`${outDir}/metadata.csv`, csv, "utf-8");
}
Run It
Add a script to package.json:
{
"scripts": {
"seo-meta": "node --loader ts-node/esm src/cli.ts"
}
}
Now run:
npm run seo-meta -- --input data/pages.json --locales en-US,en-GB,en-AU --includeLocation --preview
Check generated files:
ls -la out
cat out/metadata.csv
You now have a working localized metadata generator.
Optional: LLM-Assisted Copy with Guardrails
Deterministic templates are safe and scalable, but sometimes you want more variety. If you use an LLM, keep these principles:
- LLM generates candidates, not final output.
- Your tool validates and post-processes (locale rules, length).
- You keep a fallback template if the model output fails constraints.
Add an LLM module (example with OpenAI-compatible API)
Install a fetch polyfill only if needed (Node 18+ already has fetch):
npm install openai
Create src/llm.ts:
import OpenAI from "openai";
import { PageInput } from "./schema.js";
import { Locale } from "./locales.js";
export type LlmCandidate = { title: string; description: string };
export async function generateWithLlm(page: PageInput, locale: Locale): Promise<LlmCandidate> {
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const prompt = `
You are writing SEO metadata in ${locale} English.
Return JSON only with keys: title, description.
Constraints:
- Title: 45-60 characters, include primary keyword early.
- Description: 140-160 characters, include value prop and CTA.
- Avoid exaggerated claims like "guaranteed", "#1".
Input:
Brand: ${page.brand}
Name: ${page.name}
Category: ${page.category}
Primary keyword: ${page.primaryKeyword}
Secondary keywords: ${page.secondaryKeywords.join(", ")}
Location hint: ${page.locationHint ?? "none"}
Features: ${page.features.join(", ")}
Audience: ${page.audience ?? "general"}
`;
const resp = await client.chat.completions.create({
model: "gpt-4.1-mini",
messages: [
{ role: "system", content: "You output strict JSON only." },
{ role: "user", content: prompt }
],
temperature: 0.7
});
const text = resp.choices[0]?.message?.content ?? "{}";
const parsed = JSON.parse(text);
return { title: String(parsed.title ?? ""), description: String(parsed.description ?? "") };
}
Integrate LLM output safely
Modify generateMetadata to optionally accept candidates, then:
- Apply locale rules
- Validate lengths
- If invalid, fall back to template
A simple approach: add a new function generateMetadataFromCandidate(page, opts, candidate) that reuses the same validation and locale processing.
Important: Always treat model output as untrusted input. Your zod schemas and validators are your safety net.
Testing Your Generator
Testing matters because SEO metadata errors are easy to ship at scale.
Install a test runner:
npm install -D vitest
Add src/generator.test.ts:
import { describe, it, expect } from "vitest";
import { generateMetadata } from "./generator.js";
describe("generateMetadata", () => {
it("generates locale-specific spelling", () => {
const page = {
id: "x",
type: "product" as const,
brand: "Brand",
name: "Name",
category: "Category",
primaryKeyword: "favorite color",
secondaryKeywords: [],
locationHint: "London",
features: ["Lightweight", "Breathable"],
audience: "everyone"
};
const gb = generateMetadata(page, { locale: "en-GB", brandSuffix: false, includeLocation: true });
expect(gb.title.toLowerCase()).toContain("favourite");
expect(gb.title.toLowerCase()).toContain("colour");
});
it("adds warnings when too long", () => {
const page = {
id: "y",
type: "service" as const,
brand: "BrandBrandBrandBrandBrand",
name: "NameNameNameNameName",
category: "CategoryCategoryCategory",
primaryKeyword: "very long primary keyword phrase that keeps going",
secondaryKeywords: [],
locationHint: "Somewhere",
features: ["Feature one", "Feature two", "Feature three"],
audience: "people"
};
const meta = generateMetadata(page, { locale: "en-US", brandSuffix: true, includeLocation: true });
expect(meta.warnings.length).toBeGreaterThan(0);
});
});
Add script:
{
"scripts": {
"test": "vitest run"
}
}
Run:
npm test
Operational Tips and Common Pitfalls
1) Don’t confuse localization with adding city names everywhere
Adding in Seattle to every title can look spammy and may reduce CTR if the page isn’t truly location-specific. Make it a switch (--includeLocation) and use it only when:
- The page is a local landing page
- The business actually serves that area
- The content supports the location claim
2) Avoid duplicated titles across locales
Even if the language is English, you can differentiate by:
- Spelling differences
- Local value props (“free shipping” vs “free delivery”)
- Local compliance language
- Currency and shipping timelines
3) Keep templates stable, but allow controlled variability
If your titles all look identical, you may reduce CTR. Add controlled variation:
- Rotate benefit phrases based on features
- Use different CTAs in descriptions
- Use A/B testing for high-traffic pages
4) Validate uniqueness
At scale, you should check for duplicates:
- Same title across multiple IDs in same locale
- Same description across multiple pages
You can add a post-processing step to flag duplicates and write a report.
5) Track changes
Store generated outputs in version control or at least keep timestamped exports. Metadata changes can affect performance; you want to correlate changes with ranking/CTR movement.
Next Steps
To push this from intermediate to advanced, consider adding:
- Pixel-width estimation for titles (approximate SERP truncation more accurately).
- Duplicate detection and automatic disambiguation (e.g., append category or feature).
- CMS integrations (Shopify, WordPress, Contentful) via APIs.
- Locale packs beyond English (true translation + cultural adaptation).
- Prompt + validator pipeline if using LLMs: generate 3 candidates, score them, pick the best that passes constraints.
Appendix: Build a Production JS Bundle (Optional)
If you want compiled JS instead of ts-node, add:
npx tsc
node dist/cli.js --input data/pages.json --locales en-US,en-GB --includeLocation
Make sure your tsconfig.json and package "type": "module" settings are consistent. If you hit ESM/CJS issues, keep using ts-node/esm or adjust module settings.
You now have a complete, working Localized SEO Metadata Generator with real commands, locale-aware transformations, validation, exports, and an optional LLM extension path—built in a way that’s scalable and safe for bulk SEO operations.