← Terug naar tutorials

Hoe SEO-metadata NL snel je CTR verhoogt

seometadatalokalisatienederlandsmeta descriptiontitel tagsslugcontent marketing

SEO Metadata Generator (NL) voor Intermediate Marketeers

Doel van deze tutorial

In deze tutorial bouw je een SEO Metadata Generator die (semi-)automatisch title tags, meta descriptions, Open Graph-metadata en Twitter Cards genereert op basis van een pagina-URL, onderwerp, zoekintentie en kernkeywords. Je leert:

Deze tutorial is bedoeld voor intermediate marketeers: je kent SEO-basisbegrippen, maar wil een praktische generator die je in je workflow kunt gebruiken.


Wat is “SEO metadata” (praktisch, niet theoretisch)

1) Title tag

2) Meta description

3) Open Graph (OG) + Twitter Cards

4) Canonical & robots


Architectuur: van input naar output

We bouwen een generator die deze input accepteert:

En deze output produceert:

Daarnaast voegen we validatie toe:


Stap 0 — Benodigdheden installeren

Je kunt dit doen met Node.js of Python. Ik geef beide routes. Kies er één.

Optie A: Node.js (aanrader voor snelle CLI)

Controleer je versie:

node -v
npm -v

Maak een projectmap:

mkdir seo-metadata-generator
cd seo-metadata-generator
npm init -y

Installeer dependencies:

npm install commander chalk
npm install --save-dev prettier

Optie B: Python

Controleer je versie:

python3 --version
pip3 --version

Maak een map:

mkdir seo-metadata-generator
cd seo-metadata-generator
python3 -m venv .venv
source .venv/bin/activate

Installeer dependency (optioneel):

pip install rich

Stap 1 — Regels definiëren (wat “goede metadata” betekent)

Een generator is zo goed als zijn regels. Voor intermediate marketeers is dit het belangrijkste deel: je legt vast wat je team consistent wil doen.

1.1 Title regels (praktische set)

Aanbevolen defaults:

Voorbeeldpatronen:

Lengte:

1.2 Meta description regels

Structuur die vaak werkt:

  1. Belofte (wat leert/krijgt de gebruiker)
  2. Bewijs (template, voorbeelden, stappen, data)
  3. CTA (download, lees, vergelijk, probeer)

Voorbeeldstructuur:

Lengte:

1.3 OG/Twitter regels


Stap 2 — Implementatie in Node.js (CLI)

Maak bestand index.js:

#!/usr/bin/env node
const { Command } = require("commander");
const chalk = require("chalk");

function clamp(str, max) {
  if (!str) return "";
  if (str.length <= max) return str;
  return str.slice(0, max - 1).trimEnd() + "…";
}

function uniq(arr) {
  return [...new Set(arr.map((s) => s.trim()))].filter(Boolean);
}

function buildTitleVariants({ keyword, intent, brand, pageType, year }) {
  const y = year || new Date().getFullYear();
  const variants = [];

  if (intent === "informatie") {
    variants.push(`${keyword}: complete gids (${y})`);
    variants.push(`${keyword} uitgelegd: stappenplan + tips (${y})`);
    variants.push(`Alles over ${keyword} (${y})`);
  } else if (intent === "vergelijking") {
    variants.push(`${keyword}: vergelijking & keuzehulp (${y})`);
    variants.push(`${keyword} vergelijken: waar let je op? (${y})`);
  } else if (intent === "aankoop") {
    variants.push(`${keyword} kopen – snel geleverd${brand ? ` | ${brand}` : ""}`);
    variants.push(`Beste ${keyword} (${y})${brand ? ` | ${brand}` : ""}`);
  } else {
    variants.push(`${keyword}${brand ? ` | ${brand}` : ""}`);
  }

  // PageType nuance
  if (pageType === "landing" && brand) {
    variants.push(`${keyword} – ontdek de oplossing | ${brand}`);
  }

  return uniq(variants);
}

function buildDescriptionVariants({ keyword, secondary, intent, usp }) {
  const sec = secondary && secondary.length ? ` Ook: ${secondary.join(", ")}.` : "";
  const u = usp ? ` ${usp}` : "";
  const variants = [];

  if (intent === "informatie") {
    variants.push(`Leer ${keyword} in duidelijke stappen.${u} Inclusief voorbeelden en praktische tips.${sec}`);
    variants.push(`Op zoek naar ${keyword}? Lees deze gids met uitleg, checklist en valkuilen.${u}${sec}`);
  } else if (intent === "vergelijking") {
    variants.push(`Vergelijk ${keyword} met heldere criteria, voor- en nadelen en een keuzehulp.${u}${sec}`);
    variants.push(`Welke optie past bij jou? Ontdek de verschillen in ${keyword} en maak een betere keuze.${u}${sec}`);
  } else if (intent === "aankoop") {
    variants.push(`Bestel ${keyword} eenvoudig online.${u} Bekijk specificaties, reviews en actuele prijzen.${sec}`);
    variants.push(`Koop ${keyword} met vertrouwen.${u} Snelle levering en duidelijke informatie.${sec}`);
  } else {
    variants.push(`Ontdek ${keyword}.${u}${sec}`);
  }

  return uniq(variants);
}

function validate({ titles, descriptions, keyword }) {
  const issues = [];

  titles.forEach((t, i) => {
    if (t.length > 60) issues.push(`Title #${i + 1} te lang (${t.length}): ${t}`);
    if (!t.toLowerCase().includes(keyword.toLowerCase()))
      issues.push(`Title #${i + 1} mist keyword: ${t}`);
  });

  descriptions.forEach((d, i) => {
    if (d.length > 160) issues.push(`Description #${i + 1} te lang (${d.length}): ${d}`);
    if (!d.toLowerCase().includes(keyword.toLowerCase()))
      issues.push(`Description #${i + 1} mist keyword: ${d}`);
  });

  return issues;
}

function buildSocial({ url, title, description, image }) {
  return {
    og: {
      "og:title": title,
      "og:description": description,
      "og:url": url,
      "og:type": "website",
      "og:image": image || "",
    },
    twitter: {
      "twitter:card": "summary_large_image",
      "twitter:title": title,
      "twitter:description": description,
      "twitter:image": image || "",
    },
  };
}

const program = new Command();

program
  .name("seo-meta")
  .description("SEO Metadata Generator (NL)")
  .requiredOption("-k, --keyword <string>", "Primair keyword")
  .option("-s, --secondary <items>", "Secundaire keywords (komma-gescheiden)")
  .option("-i, --intent <type>", "Intent: informatie|vergelijking|aankoop|navigatie", "informatie")
  .option("-b, --brand <string>", "Merknaam")
  .option("-p, --pageType <type>", "Type: blog|product|categorie|landing", "blog")
  .option("-u, --url <string>", "Pagina URL")
  .option("--usp <string>", "USP/bewijs, bijv. 'Gratis template inbegrepen.'")
  .option("--image <string>", "OG image URL")
  .option("--year <number>", "Jaar voor titles")
  .option("--json", "Output als JSON")
  .parse(process.argv);

const opts = program.opts();
const secondary = opts.secondary ? opts.secondary.split(",").map((x) => x.trim()).filter(Boolean) : [];

const titlesRaw = buildTitleVariants({
  keyword: opts.keyword,
  intent: opts.intent,
  brand: opts.brand,
  pageType: opts.pageType,
  year: opts.year ? Number(opts.year) : undefined,
});

const descriptionsRaw = buildDescriptionVariants({
  keyword: opts.keyword,
  secondary,
  intent: opts.intent,
  usp: opts.usp,
});

const titles = titlesRaw.map((t) => clamp(t, 60));
const descriptions = descriptionsRaw.map((d) => clamp(d, 160));

const issues = validate({ titles, descriptions, keyword: opts.keyword });

const primaryTitle = titles[0] || "";
const primaryDescription = descriptions[0] || "";

const social = buildSocial({
  url: opts.url || "",
  title: primaryTitle,
  description: primaryDescription,
  image: opts.image,
});

const result = {
  input: {
    keyword: opts.keyword,
    secondary,
    intent: opts.intent,
    brand: opts.brand || "",
    pageType: opts.pageType,
    url: opts.url || "",
    usp: opts.usp || "",
  },
  output: {
    titles,
    descriptions,
    social,
  },
  validation: {
    issues,
    ok: issues.length === 0,
  },
};

if (opts.json) {
  process.stdout.write(JSON.stringify(result, null, 2));
} else {
  console.log(chalk.bold("\nSEO Metadata Generator (NL)\n"));
  console.log(chalk.bold("Titles:"));
  titles.forEach((t, idx) => console.log(`  ${idx + 1}. ${t} (${t.length})`));

  console.log(chalk.bold("\nMeta descriptions:"));
  descriptions.forEach((d, idx) => console.log(`  ${idx + 1}. ${d} (${d.length})`));

  console.log(chalk.bold("\nOG/Twitter (primair):"));
  console.log(JSON.stringify(social, null, 2));

  if (issues.length) {
    console.log(chalk.yellow("\nValidatie issues:"));
    issues.forEach((x) => console.log(`- ${x}`));
  } else {
    console.log(chalk.green("\nValidatie: OK"));
  }
}

Maak het uitvoerbaar:

chmod +x index.js

Voeg een bin entry toe in package.json:

{
  "name": "seo-metadata-generator",
  "version": "1.0.0",
  "bin": {
    "seo-meta": "./index.js"
  }
}

Link lokaal:

npm link

Stap 3 — Generator draaien (echte commando’s)

Voorbeeld 1: Informatieve blogpost

seo-meta \
  --keyword "SEO metadata generator" \
  --secondary "meta title, meta description, open graph" \
  --intent informatie \
  --pageType blog \
  --usp "Inclusief CLI-commando’s en validatie."

Voorbeeld 2: Landingpagina met merk

seo-meta \
  --keyword "marketing automation software" \
  --intent aankoop \
  --pageType landing \
  --brand "AcmeCRM" \
  --usp "Probeer 14 dagen gratis." \
  --url "https://example.com/marketing-automation" \
  --image "https://example.com/assets/og/marketing-automation.png"

Output als JSON (voor integraties)

seo-meta \
  --keyword "keyword research tool" \
  --intent vergelijking \
  --json > metadata.json

Stap 4 — Output omzetten naar HTML meta tags

Je generator geeft JSON; je CMS of template-engine wil HTML. Je kunt een klein script maken om tags te printen.

Maak render-html.js:

const fs = require("fs");

const inputPath = process.argv[2];
if (!inputPath) {
  console.error("Gebruik: node render-html.js metadata.json");
  process.exit(1);
}

const data = JSON.parse(fs.readFileSync(inputPath, "utf8"));
const title = data.output.titles[0];
const desc = data.output.descriptions[0];
const og = data.output.social.og;
const tw = data.output.social.twitter;

function meta(name, content) {
  if (!content) return "";
  return `<meta name="${name}" content="${escapeHtml(content)}">`;
}
function prop(property, content) {
  if (!content) return "";
  return `<meta property="${property}" content="${escapeHtml(content)}">`;
}
function escapeHtml(s) {
  return String(s)
    .replaceAll("&", "&amp;")
    .replaceAll('"', "&quot;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;");
}

const lines = [];
lines.push(`<title>${escapeHtml(title)}</title>`);
lines.push(meta("description", desc));

Object.entries(og).forEach(([k, v]) => lines.push(prop(k, v)));
Object.entries(tw).forEach(([k, v]) => lines.push(meta(k, v)));

process.stdout.write(lines.filter(Boolean).join("\n") + "\n");

Gebruik:

node render-html.js metadata.json > meta-tags.html
cat meta-tags.html

Stap 5 — Validatie: lengte, keyword, consistentie (waarom dit werkt)

5.1 Waarom lengtechecks nuttig zijn (maar niet heilig)

Google knipt af op basis van pixels. Toch zijn tekens een bruikbare proxy. Door te clampen op 60/160:

Voor gevorderde nauwkeurigheid kun je later pixelmeting toevoegen (met een font-metric library), maar voor intermediate workflows is dit vaak “goed genoeg”.

5.2 Keyword presence: geen “keyword stuffing”

De check “keyword moet erin” is een kwaliteitsguardrail, niet bedoeld om te spammen. Je wil:

Als je merk/USP belangrijker is dan exact het keyword, kun je de check versoepelen (bijv. alleen voor title verplicht, description optioneel).

5.3 Duplicaten en cannibalisatie (uitbreiding)

In grotere sites wil je voorkomen dat 20 pagina’s dezelfde title krijgen (“Complete gids (2026)”). Een simpele uitbreiding:


Stap 6 — Workflow voor marketeers: “human-in-the-loop”

Een generator is geen vervanging van copywriting; het is een versneller.

Aanbevolen workflow:

  1. Generator maakt 3–5 varianten.
  2. Marketeer kiest 1 variant en past microcopy aan (USP, tone, CTA).
  3. Validatie draait opnieuw.
  4. Publiceer + monitor CTR in GSC.
  5. Itereer op basis van data (per template/pagetype).

Praktische tip: sla per pagina ook de “reden” op (waarom variant gekozen is). Dat helpt bij latere audits.


Stap 7 — Integratie met een spreadsheet (CSV export)

Veel teams werken in Google Sheets. Voeg een CSV-export toe.

Maak export-csv.js:

const fs = require("fs");

const inputPath = process.argv[2];
const outPath = process.argv[3] || "metadata.csv";
if (!inputPath) {
  console.error("Gebruik: node export-csv.js metadata.json [output.csv]");
  process.exit(1);
}

const data = JSON.parse(fs.readFileSync(inputPath, "utf8"));

function csvEscape(v) {
  const s = String(v ?? "");
  if (s.includes('"') || s.includes(",") || s.includes("\n")) {
    return `"${s.replaceAll('"', '""')}"`;
  }
  return s;
}

const row = {
  url: data.input.url,
  keyword: data.input.keyword,
  intent: data.input.intent,
  title: data.output.titles[0] || "",
  meta_description: data.output.descriptions[0] || "",
  og_title: data.output.social.og["og:title"] || "",
  og_description: data.output.social.og["og:description"] || "",
  og_image: data.output.social.og["og:image"] || "",
};

const headers = Object.keys(row);
const line1 = headers.map(csvEscape).join(",");
const line2 = headers.map((h) => csvEscape(row[h])).join(",");

fs.writeFileSync(outPath, line1 + "\n" + line2 + "\n", "utf8");
console.log(`Geschreven: ${outPath}`);

Gebruik:

seo-meta --keyword "seo audit checklist" --intent informatie --json > one.json
node export-csv.js one.json one.csv
cat one.csv

Stap 8 — Python alternatief (kort maar bruikbaar)

Als je liever Python gebruikt, maak seo_meta.py:

#!/usr/bin/env python3
import argparse
import json
from datetime import datetime

def clamp(s, max_len):
    if s is None:
        return ""
    s = str(s)
    return s if len(s) <= max_len else s[:max_len-1].rstrip() + "…"

def uniq(items):
    seen = set()
    out = []
    for x in items:
        x = x.strip()
        if x and x not in seen:
            out.append(x)
            seen.add(x)
    return out

def build_titles(keyword, intent, brand, page_type, year):
    y = year or datetime.now().year
    v = []
    if intent == "informatie":
        v += [f"{keyword}: complete gids ({y})",
              f"{keyword} uitgelegd: stappenplan + tips ({y})",
              f"Alles over {keyword} ({y})"]
    elif intent == "vergelijking":
        v += [f"{keyword}: vergelijking & keuzehulp ({y})",
              f"{keyword} vergelijken: waar let je op? ({y})"]
    elif intent == "aankoop":
        v += [f"{keyword} kopen – snel geleverd" + (f" | {brand}" if brand else ""),
              f"Beste {keyword} ({y})" + (f" | {brand}" if brand else "")]
    else:
        v += [f"{keyword}" + (f" | {brand}" if brand else "")]
    if page_type == "landing" and brand:
        v += [f"{keyword} – ontdek de oplossing | {brand}"]
    return uniq(v)

def build_descriptions(keyword, secondary, intent, usp):
    sec = f" Ook: {', '.join(secondary)}." if secondary else ""
    u = f" {usp}" if usp else ""
    v = []
    if intent == "informatie":
        v += [f"Leer {keyword} in duidelijke stappen.{u} Inclusief voorbeelden en praktische tips.{sec}",
              f"Op zoek naar {keyword}? Lees deze gids met uitleg, checklist en valkuilen.{u}{sec}"]
    elif intent == "vergelijking":
        v += [f"Vergelijk {keyword} met heldere criteria, voor- en nadelen en een keuzehulp.{u}{sec}",
              f"Welke optie past bij jou? Ontdek de verschillen in {keyword} en maak een betere keuze.{u}{sec}"]
    elif intent == "aankoop":
        v += [f"Bestel {keyword} eenvoudig online.{u} Bekijk specificaties, reviews en actuele prijzen.{sec}",
              f"Koop {keyword} met vertrouwen.{u} Snelle levering en duidelijke informatie.{sec}"]
    else:
        v += [f"Ontdek {keyword}.{u}{sec}"]
    return uniq(v)

def validate(titles, descriptions, keyword):
    issues = []
    kw = keyword.lower()
    for i, t in enumerate(titles):
        if len(t) > 60:
            issues.append(f"Title #{i+1} te lang ({len(t)}): {t}")
        if kw not in t.lower():
            issues.append(f"Title #{i+1} mist keyword: {t}")
    for i, d in enumerate(descriptions):
        if len(d) > 160:
            issues.append(f"Description #{i+1} te lang ({len(d)}): {d}")
        if kw not in d.lower():
            issues.append(f"Description #{i+1} mist keyword: {d}")
    return issues

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--keyword", required=True)
    ap.add_argument("--secondary", default="")
    ap.add_argument("--intent", default="informatie")
    ap.add_argument("--brand", default="")
    ap.add_argument("--pageType", default="blog")
    ap.add_argument("--url", default="")
    ap.add_argument("--usp", default="")
    ap.add_argument("--image", default="")
    ap.add_argument("--year", type=int, default=0)
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    secondary = [x.strip() for x in args.secondary.split(",") if x.strip()]
    year = args.year if args.year else None

    titles = [clamp(x, 60) for x in build_titles(args.keyword, args.intent, args.brand, args.pageType, year)]
    descs = [clamp(x, 160) for x in build_descriptions(args.keyword, secondary, args.intent, args.usp)]
    issues = validate(titles, descs, args.keyword)

    social = {
        "og": {
            "og:title": titles[0] if titles else "",
            "og:description": descs[0] if descs else "",
            "og:url": args.url,
            "og:type": "website",
            "og:image": args.image,
        },
        "twitter": {
            "twitter:card": "summary_large_image",
            "twitter:title": titles[0] if titles else "",
            "twitter:description": descs[0] if descs else "",
            "twitter:image": args.image,
        }
    }

    result = {
        "input": {
            "keyword": args.keyword,
            "secondary": secondary,
            "intent": args.intent,
            "brand": args.brand,
            "pageType": args.pageType,
            "url": args.url,
            "usp": args.usp,
        },
        "output": {
            "titles": titles,
            "descriptions": descs,
            "social": social,
        },
        "validation": {
            "issues": issues,
            "ok": len(issues) == 0
        }
    }

    if args.json:
        print(json.dumps(result, ensure_ascii=False, indent=2))
    else:
        print("Titles:")
        for i, t in enumerate(titles, 1):
            print(f"  {i}. {t} ({len(t)})")
        print("\nMeta descriptions:")
        for i, d in enumerate(descs, 1):
            print(f"  {i}. {d} ({len(d)})")
        if issues:
            print("\nIssues:")
            for x in issues:
                print(f"- {x}")
        else:
            print("\nValidatie: OK")

if __name__ == "__main__":
    main()

Maak uitvoerbaar en run:

chmod +x seo_meta.py
./seo_meta.py --keyword "content strategie" --intent informatie
./seo_meta.py --keyword "crm systeem" --intent aankoop --brand "AcmeCRM" --json > out.json

Stap 9 — Praktische optimalisaties (waar intermediate het verschil maakt)

9.1 Intent-specifieke CTA’s

Voeg CTA-woorden toe per intent om consistent te blijven.

9.2 Merkvermelding slim inzetten

9.3 Jaar in titles: wanneer wel/niet

Wel:

9.4 Dynamische USP’s

Laat USP afhangen van paginatype:


Stap 10 — Meten en itereren (GSC-gedreven)

Je generator is pas waardevol als je iteraties meet.

Aanpak:

  1. Exporteer in Google Search Console:
    • Pagina’s met hoge impressions, lage CTR
  2. Genereer 3 nieuwe title/description varianten
  3. Pas aan, publiceer, wacht 14–28 dagen
  4. Vergelijk CTR en gemiddelde positie (let op: positie kan schommelen)

Praktische regel: verander niet tegelijk title én description als je wil leren wat effect had. Doe één variabele per iteratie waar mogelijk.


Samenvatting

Je hebt nu een werkende SEO Metadata Generator (NL) met:

Dit is een solide basis voor intermediate marketeers: snel varianten maken, consistentie afdwingen, en itereren op basis van data.


Volgende uitbreidingen (optioneel, maar logisch)

Als je wil, kan ik op basis van jouw site-structuur (paginatypes, merkregels, tone of voice) een set templates en validatieregels voorstellen die precies bij jullie past.