Soms werk ik aan websites waarbij de content op orde is, de laadtijden prima zijn, maar de zoekresultaten toch tegenvallen. Geen rich snippets, geen sterscore, geen FAQ-uitklappers onder het resultaat. Dat is bijna altijd een teken dat er geen gestructureerde data aanwezig is. Met JSON-LD geef je zoekmachines een gestructureerde beschrijving van wat er op een pagina staat, in een formaat dat ze direct begrijpen zonder de HTML te moeten parsen.
Wat JSON-LD doet en waarom het de voorkeur verdient
JSON-LD staat voor JavaScript Object Notation for Linked Data. Google ondersteunt ook Microdata en RDFa, maar JSON-LD heeft een groot voordeel: het staat los van de HTML. Je plaatst het in een <script type="application/ld+json"> tag, waardoor je de opmaak van de content volledig kunt scheiden van de semantische beschrijving. Bij Microdata plak je attributen rechtstreeks op HTML-elementen, wat al snel rommelig wordt bij grotere templates.
Zoekmachines lezen JSON-LD op dezelfde manier als andere scripts in de <head> of <body>. Google heeft bevestigd dat ze het ook verwerken wanneer het dynamisch door JavaScript wordt geïnjecteerd, maar ik zet het altijd server-side in de HTML. Dat voorkomt race conditions en is beter voor pagina's die door crawlers worden geïndexeerd vóórdat JavaScript volledig is uitgevoerd. In PHP of Laravel genereer ik het blok als onderdeel van de Blade-layout, met de juiste data als array die ik via json_encode naar het script schrijf.
Een FAQ-schema opbouwen met PHP en Blade
Een veelgebruikt schema in mijn projecten is FAQ-schema. Pagina's met veelgestelde vragen kunnen in de zoekresultaten uitklappen met directe antwoorden, wat de klikratio aanzienlijk verhoogt. Google vereist daarvoor een correcte implementatie van FAQPage volgens de Schema.org-specificatie.
Hier bouw ik het schema op vanuit een Laravel-controller. De vragen en antwoorden komen uit de database, en de array wordt omgezet naar een geldig JSON-LD blok:
$faqItems = Faq::where('page_id', $page->id)
->orderBy('sort_order')
->get();
$schema = [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => $faqItems->map(function ($item) {
return [
'@type' => 'Question',
'name' => $item->question,
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => strip_tags($item->answer),
],
];
})->values()->toArray(),
];
Let op strip_tags() bij het antwoord. Google accepteert basis HTML in het text-veld, maar het is veiliger om plain text te sturen, zeker als de antwoorden via een WYSIWYG-editor zijn ingevuld. Daarna schrijf ik het blok in de Blade-template:
@if (!empty($schema))
<script type="application/ld+json">
{!! json_encode($schema, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) !!}
</script>
@endif
JSON_UNESCAPED_UNICODE zorgt ervoor dat Nederlandse tekens zoals é, ë en ij niet worden omgezet naar Unicode-escape-sequences. Dat maakt het schema leesbaarder en voorkomt onnodige problemen bij validatie.
Product- en BreadcrumbList-schema combineren
Bij webshopprojecten combineer ik meerdere schemas op één pagina. Google staat dit toe zolang elk schema correct is. Op een productpagina zet ik naast het Product-schema altijd ook een BreadcrumbList neer. Die breadcrumb verschijnt dan in de zoekresultaten onder de URL, waardoor de bezoeker direct ziet in welke categorie een product valt.
Twee aparte <script type="application/ld+json"> blokken op dezelfde pagina is de netste aanpak. Je kunt ze ook samenvoegen in een array met @graph, maar ik houd ze liever gescheiden zodat ik per component verantwoordelijkheid kan toewijzen. Het Product-schema zit dan in het productcomponent, het BreadcrumbList-schema in de layout-laag die de paginastructuur kent.
$breadcrumbSchema = [
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => [],
];
$position = 1;
foreach ($breadcrumbs as $crumb) {
$breadcrumbSchema['itemListElement'][] = [
'@type' => 'ListItem',
'position' => $position,
'name' => $crumb->label,
'item' => url($crumb->url),
];
$position++;
}
url() in Laravel geeft een absolute URL terug, inclusief het protocol. Schema.org vereist absolute URLs bij item in een BreadcrumbList, dus relatieve paden zorgen hier voor validatiefouten in Google's Rich Results Test. Dit is een fout die ik eerder maakte toen ik handmatige padstrings doorgaf in plaats van de helper te gebruiken.
Valideren en monitoren zodat het ook echt werkt
Het bouwen van het schema is de helft van het werk. Valideren is net zo belangrijk, want een klein typefoutje in de @type-waarde of een ontbrekend verplicht veld zorgt ervoor dat Google het schema negeert. Google's eigen Rich Results Test is mijn eerste controle: die laat zien welke schemas herkend worden en of er verplichte velden ontbreken.
Schema.org heeft voor elk type een eigen set verplichte en aanbevolen eigenschappen. Voor Product zijn name en ofwel een offers-object ofwel een review verplicht om in aanmerking te komen voor rich snippets. Voor FAQPage moeten minimaal twee Question-objecten aanwezig zijn. Google documenteert dit per type op developers.google.com/search, en die pagina's zijn mijn referentie wanneer ik iets nieuws opzet.
Google Search Console toont na indexering of er fouten zijn gevonden in de gestructureerde data. Die informatie verschijnt onder "Verbeteringen" in de linkernavigatie. Voordeel van Search Console boven de Rich Results Test is dat je de status over tijd kunt volgen. Soms loopt het een paar dagen voordat Google de gewijzigde pagina's opnieuw heeft gecrawld, dus directe feedback is niet altijd mogelijk. Wat ik doe om dat te versnellen: na een implementatie stuur ik de URL via de URL Inspection Tool in Search Console ter herindexering.
Eén patroon dat ik consequent toepas is het schrijven van een aparte service-klasse in Laravel die het schema voor een bepaald type opbouwt. Zo'n klasse ontvangt het model en geeft een array terug. Dat maakt het testbaar via unit tests, wat handig is als de datastructuur ooit verandert:
class ProductSchemaBuilder
{
public function build(Product $product): array
{
return [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $product->name,
'image' => $product->images->map(fn($img) => $img->url)->toArray(),
'description' => $product->meta_description,
'sku' => $product->sku,
'offers' => [
'@type' => 'Offer',
'url' => route('products.show', $product->slug),
'priceCurrency' => 'EUR',
'price' => number_format($product->price, 2, '.', ''),
'availability' => $product->in_stock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
],
];
}
}
number_format met een punt als decimaalteken is hier bewust. JSON-LD verwacht getallen in het internationaal formaat, dus een komma als decimaalscheiding zorgt voor een ongeldige waarde. Dit is een subtiel probleem dat in de Rich Results Test direct een foutmelding geeft, maar in de productieomgeving pas opvalt na een paar weken wanneer je je afvraagt waarom de sterren nog steeds niet verschijnen.
Gestructureerde data is niet de meest glamoureuze kant van webdevelopment, maar het is wel een van de weinige plekken waar je als developer rechtstreeks invloed hebt op hoe een pagina eruitziet in de zoekresultaten. Dat vind ik er juist leuk aan: het is meetbaar, het is specifiek, en als het goed staat hoef je er maanden niet meer naar om te kijken.