FAQ and HowTo schema remain some of the highest-impact structured-data types for WordPress content sites — FAQ schema can produce rich result accordions in search, and HowTo schema displays step images and timing in visual results. Generating these schemas programmatically from CPTs or ACF fields ensures every qualifying page has accurate, up-to-date markup without manual maintenance.
Problem: A WordPress site publishes hundreds of FAQ and How-To posts, but the structured data markup is added manually per post — there is no automated system to generate FAQPage and HowTo JSON-LD at scale.
Solution: Build a programmatic schema injection system: register custom ACF fields (faq_items, howto_steps) on relevant post types, then hook into wp_head to read those fields and output JSON-LD. Use a template function that builds the schema array from post meta and encodes it with json_encode(JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES). Validate a sample with Google's Rich Results Test.
The code below generates FAQ schema from a CPT, HowTo schema from structured ACF fields, and an aggregate schema that combines both on the same page — all output in <script type="application/ld+json"> via wp_head.
post_type === 'faq' ) {
$qa_pairs = get_post_meta( $post->ID, '_faq_items', true ); // array of [q, a]
if ( is_array( $qa_pairs ) && $qa_pairs ) {
$schemas[] = [
'@context' => 'https://schema.org',
'@type' => 'FAQPage',
'mainEntity' => array_map( fn( $item ) => [
'@type' => 'Question',
'name' => esc_html( $item['question'] ),
'acceptedAnswer' => [
'@type' => 'Answer',
'text' => wp_kses( $item['answer'], [ 'a' => [ 'href' => [] ], 'strong' => [], 'em' => [] ] ),
],
], $qa_pairs ),
];
}
}
// ── HowTo Schema from CPT 'howto' with ACF fields ────────────────
if ( $post->post_type === 'howto' ) {
$steps = get_post_meta( $post->ID, '_howto_steps', true ); // array of [name, text, image_id]
$total_time = get_post_meta( $post->ID, '_howto_total_time', true ); // ISO 8601 e.g. PT30M
if ( is_array( $steps ) && $steps ) {
$schema = [
'@context' => 'https://schema.org',
'@type' => 'HowTo',
'name' => get_the_title(),
'totalTime' => $total_time ?: null,
'step' => array_values( array_map( fn( $step, $i ) => [
'@type' => 'HowToStep',
'position' => $i + 1,
'name' => esc_html( $step['name'] ),
'text' => esc_html( $step['text'] ),
'image' => isset( $step['image_id'] )
? [ '@type' => 'ImageObject', 'url' => wp_get_attachment_url( (int) $step['image_id'] ) ]
: null,
], $steps, array_keys( $steps ) ) ),
];
// Remove null image entries
$schema['step'] = array_map( fn( $s ) => array_filter( $s ), $schema['step'] );
$schemas[] = $schema;
}
}
foreach ( $schemas as $schema ) {
printf(
'' . "\n",
wp_json_encode( $schema, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT )
);
}
} );
NOTE: Google's Rich Results Test has a 10,000-character limit on structured data it will validate in the UI — for FAQ pages with many questions or HowTo pages with many steps, check the raw JSON size with strlen(wp_json_encode($schema)) and consider paginating very long FAQ lists across multiple URLs rather than cramming hundreds of Q&A pairs into one schema object.