← Back to Tutorials

Localized SEO Metadata Generator (English) | Intermediate Guide

localized seoseo metadatameta titlemeta descriptionslug optimizationkeyword researchon-page seocontent localization

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:


Table of Contents

  1. What “Localized SEO Metadata” Means
  2. SEO Constraints You Must Enforce
  3. Project Setup
  4. Data Model: Input and Output
  5. Locale Rules: en-US vs en-GB vs en-AU
  6. Implementation: Generator Core
  7. Length Checking and Snippet Preview
  8. Keyword Expansion (Deterministic)
  9. CLI: Generate Metadata for Many Pages
  10. Export to CSV and JSON
  11. Optional: LLM-Assisted Copy with Guardrails
  12. Testing Your Generator
  13. Operational Tips and Common Pitfalls
  14. Next Steps

What “Localized SEO Metadata” Means

SEO metadata usually refers to:

Localization is not only translation. Even within English, different locales have:

A localized SEO metadata generator should:

  1. Keep brand voice consistent.
  2. Apply locale-specific spelling/terms.
  3. Include location cues when appropriate (without keyword stuffing).
  4. Respect snippet length and avoid truncation.
  5. 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

Meta description constraints

Safety and compliance constraints

You may need:

We’ll implement:


Project Setup

We’ll build a Node.js-based CLI tool.

Prerequisites

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

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 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


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:

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:

  1. LLM generates candidates, not final output.
  2. Your tool validates and post-processes (locale rules, length).
  3. 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:

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:

2) Avoid duplicated titles across locales

Even if the language is English, you can differentiate by:

3) Keep templates stable, but allow controlled variability

If your titles all look identical, you may reduce CTR. Add controlled variation:

4) Validate uniqueness

At scale, you should check for duplicates:

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:

  1. Pixel-width estimation for titles (approximate SERP truncation more accurately).
  2. Duplicate detection and automatic disambiguation (e.g., append category or feature).
  3. CMS integrations (Shopify, WordPress, Contentful) via APIs.
  4. Locale packs beyond English (true translation + cultural adaptation).
  5. 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.