What you are building
Internal linking is the highest-leverage SEO work most editors skip because it is slow and subjective. Embeddings make the matching part fast: turn the current post’s title and excerpt into a vector, compare it with vectors already stored for published posts, and rank by cosine similarity.
This tutorial builds a Gutenberg sidebar that does exactly that, but it stops short of editing. The sidebar requests five candidates from a capability-checked REST endpoint. Each candidate appears with a score. Nothing enters the post until the editor clicks Approve, at which point a single core/paragraph block containing an escaped anchor is inserted through the block editor’s data layer.
The endpoint returns data, not markup. The editor builds the block. That separation is what keeps this safe and reviewable.
Prerequisites
- WordPress 6.5 or later and PHP 8.1 or later. The code uses typed properties and union return types.
- Node 20+ and
@wordpress/scriptsfor the sidebar build. Runnpx @wordpress/scripts buildafter scaffolding. - An embeddings API key. Keep it in
wp-config.phpasELS_API_KEY; do not store it in an option that anymanage_optionsuser can read. - A staging site with at least 20 published posts. Similarity search is meaningless on three posts.
- Familiarity with register_rest_route and the @wordpress/plugins package.
Architecture and data flow
- On publish, a
save_post_posthook combines the title and excerpt (or the first 60 words of content), requests an embedding, and stores the vector in post meta. - In the editor, the sidebar calls
/els/v1/suggest?post_id=123with the REST nonce in theX-WP-Nonceheader. - The endpoint re-embeds the current title and excerpt, loads stored vectors for up to 200 other published posts, and computes cosine similarity.
- It returns the top five candidates above the configured threshold as
{ id, title, link, score }objects. No HTML, no rendered anchor tags. - The editor approves one candidate. The sidebar escapes the title, builds an anchor string, wraps it in a paragraph block, and inserts it.
Why this shape? Because embeddings drift when you change models, because thresholds are editorial policy, and because automatic insertion is how sites end up with five links to the same post. The approval step is not a UX nicety; it is the quality gate.
Step 1 Plugin scaffold, threshold setting, and sanitisation
Start with a single plugin file. Register one option that holds the similarity threshold and the model name. Sanitise on write so a bad settings form cannot store a threshold of 2.0 or a model string that gets interpolated into an API request.
<?php
/**
* Plugin Name: Embedding Link Suggestions
* Description: Suggests internal links from stored post embeddings.
* Version: 0.1.0
*/
defined( 'ABSPATH' ) || exit;
define( 'ELS_OPTION', 'els_settings' );
define( 'ELS_META', '_els_embedding' );
define( 'ELS_MODEL', 'text-embedding-3-small' );
add_action( 'init', 'els_register_settings' );
function els_register_settings(): void {
register_setting( 'els', ELS_OPTION, [
'type' => 'array',
'sanitize_callback' => 'els_sanitize_settings',
'default' => [
'threshold' => 0.78,
'model' => ELS_MODEL,
],
] );
}
function els_sanitize_settings( array $raw ): array {
return [
'threshold' => min( 0.99, max( 0.50, (float) ( $raw['threshold'] ?? 0.78 ) ) ),
'model' => sanitize_text_field( $raw['model'] ?? ELS_MODEL ),
];
}
Why this matters: the threshold is the difference between showing anything vaguely related and showing only strong matches. Storing it as a bounded float means the REST endpoint does not need to defend against nonsensical values at request time. The model name is stored for the same reason: changing it later should invalidate cached embeddings, and the cache key in the next step includes it.
Step 2 Embedding service with transient caching
Wrap the provider call in one function. The cache key combines the text and the model, so switching from a small model to a larger one does not serve vectors of the wrong dimension. The $fresh flag bypasses the cache on publish, where you want the current content, not a stale transient.
function els_embed( string $text, bool $fresh = false ): array|WP_Error {
$text = trim( wp_strip_all_tags( $text ) );
if ( '' === $text ) {
return new WP_Error( 'els_empty', 'Nothing to embed.', [ 'status' => 400 ] );
}
$key = 'els_emb_' . md5( $text . ELS_MODEL );
if ( ! $fresh ) {
$cached = get_transient( $key );
if ( is_array( $cached ) ) {
return $cached;
}
}
$response = wp_remote_post( 'https://api.openai.com/v1/embeddings', [
'timeout' => 15,
'headers' => [
'Authorization' => 'Bearer ' . ELS_API_KEY,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode( [
'model' => ELS_MODEL,
'input' => $text,
] ),
] );
if ( is_wp_error( $response ) ) {
return $response;
}
$code = wp_remote_retrieve_response_code( $response );
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( 200 !== $code || empty( $body['data'][0]['embedding'] ) ) {
return new WP_Error( 'els_api', 'Embedding API failed.', [ 'status' => 502, 'code' => $code ] );
}
$vector = array_map( 'floatval', $body['data'][0]['embedding'] );
set_transient( $key, $vector, DAY_IN_SECONDS );
return $vector;
}
Use the Transients API for this request-level cache because embeddings for identical text are common when editors re-save a post. A one-day expiry is a sensible default; on a high-traffic site, move this to an object cache group with a longer TTL. Do not cache errors: a temporary 429 from the provider should not poison the key for 24 hours.
Step 3 Store each post’s embedding on publish
Embeddings belong to the post, so post meta is the simplest durable store. The hook ignores revisions and autosaves, and it only runs for published posts. For a custom post type, add a second hook or a post-type array.
add_action( 'save_post_post', 'els_store_embedding', 20, 3 );
function els_store_embedding( int $post_id, WP_Post $post, bool $update ): void {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( 'publish' !== $post->post_status ) {
return;
}
$excerpt = $post->post_excerpt ?: wp_trim_words( $post->post_content, 60 );
$source = $post->post_title . PHP_EOL . PHP_EOL . wp_strip_all_tags( $excerpt );
$vector = els_embed( $source, true );
if ( is_wp_error( $vector ) ) {
update_post_meta( $post_id, '_els_embedding_error', $vector->get_error_message() );
return;
}
update_post_meta( $post_id, ELS_META, $vector );
delete_post_meta( $post_id, '_els_embedding_error' );
}
Why save_post_post and not save_post? Because the narrower hook avoids accidentally embedding attachments, navigation menus, or reusable blocks during unrelated saves. If you later add a book post type, register save_post_book explicitly. The error meta key is a debugging aid: it lets you list failed posts with WP_Query instead of digging through logs.
Step 4 A capability-checked REST endpoint for top-five candidates
The endpoint must answer two questions before it does any work: is this a real post, and can the current user edit it? The permission_callback handles the second. The callback handles the first. Never trust a post_id from the client without checking the edit_post meta capability; current_user_can maps to the correct post-level capability for the user’s role.
add_action( 'rest_api_init', 'els_register_rest' );
function els_register_rest(): void {
register_rest_route( 'els/v1', '/suggest', [
'methods' => WP_REST_Server::READABLE,
'callback' => 'els_rest_suggest',
'permission_callback' => static function ( WP_REST_Request $request ) {
$post_id = absint( $request->get_param( 'post_id' ) );
return $post_id && current_user_can( 'edit_post', $post_id );
},
'args' => [
'post_id' => [
'required' => true,
'sanitize_callback' => 'absint',
],
],
] );
}
function els_rest_suggest( WP_REST_Request $request ): WP_REST_Response|WP_Error {
$post_id = absint( $request['post_id'] );
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'els_missing', 'Post not found.', [ 'status' => 404 ] );
}
$excerpt = $post->post_excerpt ?: wp_trim_words( $post->post_content, 60 );
$needle = els_embed( $post->post_title . PHP_EOL . PHP_EOL . wp_strip_all_tags( $excerpt ) );
if ( is_wp_error( $needle ) ) {
return $needle;
}
$settings = get_option( ELS_OPTION, [] );
$threshold = (float) ( $settings['threshold'] ?? 0.78 );
$scores = [];
$others = get_posts( [
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 200,
'post__not_in' => [ $post_id ],
'fields' => 'ids',
'no_found_rows' => true,
] );
foreach ( $others as $other_id ) {
$vector = get_post_meta( $other_id, ELS_META, true );
if ( ! is_array( $vector ) || count( $vector ) !== count( $needle ) ) {
continue;
}
$score = els_cosine( $needle, $vector );
if ( $score >= $threshold ) {
$scores[ $other_id ] = $score;
}
}
arsort( $scores );
$top = array_slice( $scores, 0, 5, true );
$out = [];
foreach ( $top as $id => $score ) {
$out[] = [
'id' => $id,
'title' => get_the_title( $id ),
'link' => get_permalink( $id ),
'score' => round( $score, 4 ),
];
}
return rest_ensure_response( $out );
}
function els_cosine( array $a, array $b ): float {
$dot = $na = $nb = 0.0;
foreach ( $a as $i => $v ) {
$dot += $v * $b[ $i ];
$na += $v * $v;
$nb += $b[ $i ] * $b[ $i ];
}
return ( $na && $nb ) ? $dot / ( sqrt( $na ) * sqrt( $nb ) ) : 0.0;
}
Why return only five? Because a sidebar with 40 candidates is a list, not a decision. Five is enough to cover the likely topical neighbours without turning the panel into another backlog. The posts_per_page cap of 200 is a deliberate ceiling: loading 5,000 vectors into PHP memory will exhaust typical memory_limit values. On a large site, batch the comparison in a custom table or push it to a vector database and keep this endpoint as the approval surface.
Step 5 Enqueue editor assets and wire the REST nonce
The sidebar runs in the block editor, so it needs the editor asset hook and a nonce. wp_create_nonce( 'wp_rest' ) is the correct action for REST requests. The nonce is not authentication on its own; it proves the request came from a page where the user was logged in, and the REST API’s cookie authentication still applies.
add_action( 'enqueue_block_editor_assets', 'els_editor_assets' );
function els_editor_assets(): void {
$asset_file = plugin_dir_path( __FILE__ ) . 'build/index.asset.php';
if ( ! file_exists( $asset_file ) ) {
return;
}
$asset = include $asset_file;
wp_enqueue_script(
'els-editor',
plugins_url( 'build/index.js', __FILE__ ),
$asset['dependencies'],
$asset['version'],
true
);
$settings = get_option( ELS_OPTION, [] );
wp_localize_script( 'els-editor', 'ELS', [
'nonce' => wp_create_nonce( 'wp_rest' ),
'threshold' => (float) ( $settings['threshold'] ?? 0.78 ),
] );
}
Why localise the threshold too? So the sidebar can display the current policy without a second REST round trip. It is a display value only; the endpoint reads the option again and enforces it. Never let the client decide the threshold it is judged against.
Step 6 Build the sidebar and insert only on approval
The sidebar is a PluginSidebar registered with registerPlugin. The fetch uses apiFetch with the nonce header. The approve handler is the important part: it escapes the title, builds an anchor string, and passes the string to createBlock( 'core/paragraph' ). It does not touch innerHTML, and it does not inject markup into the DOM.
import { registerPlugin } from '@wordpress/plugins';
import { PluginSidebar, PluginSidebarMoreMenuItem } from '@wordpress/editor';
import { useState } from '@wordpress/element';
import { Button, Notice, Spinner } from '@wordpress/components';
import { useSelect, useDispatch } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { store as blockEditorStore } from '@wordpress/block-editor';
import { createBlock } from '@wordpress/blocks';
import apiFetch from '@wordpress/api-fetch';
const escapeHtml = ( value ) =>
value
.replace( /&/g, '&' )
.replace( /</g, '<' )
.replace( />/g, '>' )
.replace( /"/g, '"' );
function Sidebar() {
const { postId } = useSelect( ( select ) => ( {
postId: select( editorStore ).getCurrentPostId(),
} ), [] );
const [ state, setState ] = useState( { loading: false, items: [], error: '' } );
const { insertBlocks } = useDispatch( blockEditorStore );
const fetchSuggestions = async () => {
setState( { loading: true, items: [], error: '' } );
try {
const items = await apiFetch( {
path: `/els/v1/suggest?post_id=${ postId }`,
headers: { 'X-WP-Nonce': ELS.nonce },
} );
setState( { loading: false, items, error: '' } );
} catch ( error ) {
setState( { loading: false, items: [], error: error.message } );
}
};
const approve = ( item ) => {
const anchor = `<a href="${ encodeURI( item.link ) }">${ escapeHtml( item.title ) }</a>`;
const block = createBlock( 'core/paragraph', {
content: `Related: ${ anchor }`,
} );
insertBlocks( block );
setState( ( previous ) => ( {
...previous,
items: previous.items.filter( ( candidate ) => candidate.id !== item.id ),
} ) );
};
return (
<>
<PluginSidebarMoreMenuItem target='els-sidebar'>
Link suggestions
</PluginSidebarMoreMenuItem>
<PluginSidebar name='els-sidebar' title='Link suggestions'>
<div className='els-panel'>
<p>Similarity threshold: { ELS.threshold }</p>
<Button variant='primary' onClick={ fetchSuggestions } disabled={ state.loading }>
{ state.loading ? <Spinner /> : 'Find similar posts' }
</Button>
{ state.error && (
<Notice status='error' isDismissible={ false }>
{ state.error }
</Notice>
) }
<ul>
{ state.items.map( ( item ) => (
<li key={ item.id }>
<strong>{ item.title }</strong>{ ' ' }
<span>{ item.score }</span>{ ' ' }
<Button variant='secondary' onClick={ () => approve( item ) }>
Approve
</Button>
</li>
) ) }
</ul>
{ ! state.loading && ! state.items.length && ! state.error && (
<p>No candidates above the threshold.</p>
) }
</div>
</PluginSidebar>
</>
);
}
registerPlugin( 'els-link-suggestions', { render: Sidebar } );
Why createBlock rather than direct DOM editing? Because the block editor owns serialisation. A paragraph block with an anchor in its content attribute is valid block markup, undoable, and compatible with patterns and block bindings. Building the anchor string from an escaped title and an encoded URL keeps the data boundary explicit: the REST response supplies plain values, and the editor decides how those values become markup.
If you ever find yourself writing
element.innerHTML = response.htmlin a Gutenberg sidebar, stop. The endpoint should return values, and the editor should build blocks from those values.
Troubleshooting
- 403 or
rest_forbidden: the nonce is stale, the user lacksedit_postfor that post, or the request is not sending cookies. Log out and back in, then confirm theX-WP-Nonceheader is present in the network tab. - No candidates returned: check the threshold first, then confirm
_els_embeddingexists for other posts. If you changed the model, every stored vector has the wrong dimension and is skipped by thecount()guard. Re-index. - Endpoint times out: the embedding provider is slow or the 200-post loop is heavy. Increase the transient TTL, reduce the batch size, or queue re-indexing with Action Scheduler.
- Inserted text looks like raw HTML: the block’s
contentattribute is being treated as plain text because the block type is wrong. Usecore/paragraphand keep the anchor inside the string, not as a separate attribute. - Scores look identical: you are comparing a vector with itself. The
post__not_inargument excludes the current post, but double-check that the REST request is sending the correctpost_id.
Next steps
Turn the prototype into an editorial workflow. Add a settings page under Settings → Link Suggestions with a threshold slider and a re-index all posts button. Add a WP-CLI command that loops through published posts and calls els_store_embedding() so you can rebuild after a model change.
On the front end, register a block pattern called Related reading and a Query Loop variation that reads approved links from a separate meta key. If you prefer no custom block, register a block bindings source for _els_related and bind a paragraph’s content to it; the block editor will resolve the value server-side. Keep the approval data separate from the raw candidates so the front end never renders an unapproved link.
Finally, log approvals. A simple counter in post meta or a custom table tells you which candidates editors accept. That feedback is more valuable than a lower threshold: it shows which similarity scores correspond to links humans actually trust.
Leave a Reply