How to suggest internal links with REST in the block editor (JS) in WordPress

Contents

Introduction

This tutorial shows everything you need to implement internal link suggestions in the WordPress block editor (Gutenberg) using the REST API and JavaScript. It covers built-in REST endpoints, a production-safe client-side implementation (debouncing, escaping, nonces), an optional custom REST endpoint (server-side) when you need custom ranking/fields, examples integrating the suggestion UI into a custom block, and security/performance best practices. All code examples are included and ready to use.

What this solves

  • Provide real-time suggestions of internal content while an editor types a link query inside a block editor UI.
  • Use WP REST endpoints to return title, URL, excerpt, ID and type for suggested targets.
  • Hook suggestions into a custom block (or a panel/inspector) so editors can pick a post/page to link to quickly.

Prerequisites

  • WordPress 5.0 (block editor environment). Built-in endpoint /wp/v2/search is available on modern WP versions.
  • Familiarity with JavaScript/React and Gutenberg @wordpress/ packages (or using global wp. packages).
  • Ability to register/enqueue editor scripts from PHP for a plugin or theme.

Overview of approaches

  • Client-only (recommended for most cases): Use the built-in REST endpoint /wp/v2/search or /wp/v2/posts (with search param). No server changes required.
  • Custom REST endpoint (advanced): Register a custom route to return additional metadata, custom ranking, or to exclude certain post types in a special way. Use when built-in endpoints do not meet requirements.
  • Hybrid: Use built-in endpoint for quick suggestions and fall back to a custom endpoint for advanced query/ranking on demand.

REST endpoints to use

The WordPress REST API has endpoints you can use for search/suggestions:

  • /wp/v2/search — lightweight search endpoint specifically for search suggestions. Accepts search, per_page, type (post, term, post_type), etc. Returns objects like { id, title, url, type, subtype } (title is string) and is used by many core editor features for suggestions.
  • /wp/v2/posts — you can use posts endpoint with search param and _fields to limit returned fields: e.g. /wp/v2/posts?search=term_fields=id,title,excerpt,link. This returns richer post fields but is heavier and may return rendered HTML in title/excerpt unless you request specific fields.

Client-side example: simple but robust suggester using /wp/v2/search

Below is a production-oriented React component that queries /wp/v2/search as the user types, debounces requests, handles loading/error states, and returns a simple suggestion list. You can embed this component into a block edit UI or an inspector control.

/
  LinkSuggester.js
 
  Dependencies:
   - @wordpress/element (React) -> import { useState, useEffect, useRef } from @wordpress/element
   - @wordpress/api-fetch -> import apiFetch from @wordpress/api-fetch
   - @wordpress/components -> import { TextControl, Spinner } from @wordpress/components
 
  This component queries /wp/v2/search for suggestions and returns
  a selection callback with the chosen item { id, title, url, type }.
 /
import { useState, useEffect, useRef } from @wordpress/element
import apiFetch from @wordpress/api-fetch
import { TextControl, Spinner } from @wordpress/components

function debounce(fn, wait) {
  let t
  return function (...args) {
    clearTimeout(t)
    t = setTimeout(() => fn.apply(this, args), wait)
  }
}

export default function LinkSuggester({ initialQuery = , onSelect }) {
  const [query, setQuery] = useState(initialQuery)
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(null)
  const lastQueryRef = useRef()
  const abortControllerRef = useRef(null)

  // Debounced fetch function
  const fetchSuggestions = debounce((q) => {
    if (!q  q.length < 2) {
      setResults([])
      setLoading(false)
      return
    }

    // Cancel previous request
    if (abortControllerRef.current) {
      abortControllerRef.current.abort()
    }
    const controller = new AbortController()
    abortControllerRef.current = controller

    setLoading(true)
    setError(null)
    lastQueryRef.current = q

    // Use the built-in search endpoint. Adjust per_page as needed.
    const path = /wp/v2/search?search={encodeURIComponent(q)}per_page=10type=post

    apiFetch({ path, signal: controller.signal })
      .then((items) => {
        // If the user typed further, ignore stale responses
        if (lastQueryRef.current !== q) return
        // items is an array of { id, title, url, type, subtype }
        setResults(items  [])
      })
      .catch((err) => {
        if (err.name === AbortError) return
        console.error(LinkSuggester fetch error, err)
        setError(Unable to fetch suggestions)
        setResults([])
      })
      .finally(() => {
        if (lastQueryRef.current === q) {
          setLoading(false)
        }
      })
  }, 250) // 250ms debounce

  useEffect(() => {
    fetchSuggestions(query)
    // cleanup on unmount
    return () => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort()
      }
    }
  }, [query])

  return (
    
setQuery(v)} placeholder=Search for internal posts or pages... /> {loading } {error
{error}
} {results results.length > 0 (
    {results.map((item) => (
  • ))}
)} {!loading query.length >= 2 results.length === 0 (
No results
)}
) }

Notes about the client component

  • Debounce helps avoid firing an API request on every keystroke (helpful for performance and rate limiting).
  • Minimum query length (e.g., 2 characters) avoids overly broad queries.
  • AbortController cancels outstanding requests when a new one is started. apiFetch supports the signal option.
  • apiFetch automatically attaches the REST nonce in editor context, so you do not need to manage the nonce manually when enqueued correctly.

Integrating the suggester into a custom block

You can include this component in the edit function of a block. When a user selects an item, store its ID and URL in block attributes and render an anchor tag in save().

/
  register-block.js
 
  Minimal block that allows an editor to pick an internal link using the LinkSuggester
  and then saves an anchor in the frontend output.
 /
import { registerBlockType } from @wordpress/blocks
import { useState } from @wordpress/element
import LinkSuggester from ./LinkSuggester

registerBlockType(my-plugin/internal-link, {
  title: Internal Link (picker),
  icon: admin-links,
  category: common,
  attributes: {
    linkId: { type: number },
    linkUrl: { type: string },
    linkText: { type: string }
  },
  edit: ({ attributes, setAttributes }) => {
    const { linkId, linkUrl, linkText } = attributes
    const [showPicker, setShowPicker] = useState(false)

    return (
      
{showPicker ( { setAttributes({ linkId: item.id, linkUrl: item.url, linkText: item.title, }) setShowPicker(false) }} /> )}
setAttributes({ linkText: e.target.value })} placeholder=Anchor text style={{ width: 100%, padding: 8, marginTop: 6 }} />
Selected:
{linkUrl ? ( {linkText linkUrl} ) : (
No link selected
)}
) }, save: ({ attributes }) => { const { linkUrl, linkText } = attributes // Frontend output: simple anchor tag (no server-side rendering) if (!linkUrl) return null return ( {linkText linkUrl} ) }, })

Enqueue/register the block script from PHP

Register the block script and ensure apiFetch nonce is available to the editor. Enqueue your build that contains the block and component code.

 my-plugin-block,
    ) )
}
add_action( init, my_plugin_register_block )
?>

When to use a custom REST endpoint

Use a custom endpoint when you need:

  • Custom ranking (e.g., prioritize recent content, weighted by taxonomy or meta).
  • Extra fields not available from /wp/v2/search or posts (e.g., canonical slug, custom teaser field).
  • Access control: e.g. show unpublished content to editors only, with capability checks.
  • Complex joins or relationships that require server-side logic.

Server-side: Register a custom REST route for link suggestions

This example registers a read-only route at /my-plugin/v1/link-suggest that accepts a search=q query param and returns JSON array of objects { id, title, excerpt, url, type }.

 WP_REST_Server::READABLE,
        callback            => my_plugin_link_suggest_callback,
        permission_callback => function ( request ) {
            // Optional: allow only logged-in users to see private/unpublished
            // Or simply return true for public suggestions.
            return true
        },
        args => array(
            q => array(
                required => true,
                sanitize_callback => sanitize_text_field,
            ),
            per_page => array(
                required => false,
                sanitize_callback => absint,
                default => 10,
            ),
        ),
    ) )
} )

function my_plugin_link_suggest_callback( WP_REST_Request request ) {
    q = request->get_param( q )
    per_page = request->get_param( per_page ) ?: 10

    if ( strlen( q ) < 2 ) {
        return new WP_Error( invalid_query, Query too short, array( status => 400 ) )
    }

    args = array(
        s => q,
        post_type => array( post, page ),
        posts_per_page => per_page,
        post_status => publish,
        fields => ids, // we fetch IDs and then get minimal fields to control output
    )

    query = new WP_Query( args )
    results = array()

    if ( query->have_posts() ) {
        foreach ( query->posts as post_id ) {
            post = get_post( post_id )
            if ( ! post ) {
                continue
            }
            results[] = array(
                id      => post->ID,
                title   => get_the_title( post ),
                excerpt => wp_trim_words( strip_tags( post->post_excerpt ? post->post_excerpt : post->post_content ), 20 ),
                url     => get_permalink( post ),
                type    => post->post_type,
            )
        }
    }

    return rest_ensure_response( results )
}
?>

Client usage of the custom endpoint

The client component can call the custom endpoint similarly to the built-in search endpoint. Replace the path in apiFetch with the custom route:

// example call using apiFetch
apiFetch({ path: /my-plugin/v1/link-suggest?q={encodeURIComponent(query)}per_page=8 })
  .then( ( items ) => {
    // items: [{ id, title, excerpt, url, type }, ...]
  })

Security, sanitization and permissions

  • Sanitize input on server side: sanitize_text_field for simple queries and validate ints for pagination.
  • Apply permission checks if suggestion endpoint should return non-public content. Use current_user_can(edit_posts) or a custom capability.
  • Never return raw post content without properly escaping or limiting use trimmed excerpt or generated teaser to avoid large payloads or leaking private content.
  • Use rest_ensure_response() so the REST API sends proper headers and formatting.
  • Enforce a sensible per_page limit (e.g., 5–20) to prevent excessive data transfer.

Performance and caching

  • Debounce requests to reduce load from fast typers (250ms–400ms is a good default).
  • Use caching for repeated queries on server-side: transient caching keyed by query post_status post_type. Example: set_transient( link_suggest_ . md5( q ), results, HOUR_IN_SECONDS )
  • On large sites, consider indexing or building a lightweight search index (ElasticSearch, Algolia) for much faster rank/scoring.
  • Return minimal fields (_fields in WP REST) to reduce payload size.

UX considerations

  • Show a minimum number of characters before searching (e.g., 2 or 3) to avoid an overwhelming number of suggestions.
  • Expose the type (post/page) and show the URL or path, so editors know where the link points.
  • Allow keyboard navigation (up/down enter) inside suggestion lists for editor efficiency. If you need full accessibility/keyboard support, use a combobox implementation or @wordpress/components Combobox-like patterns.
  • Provide an option to open the selected item in a new tab (preview) so editors can verify target pages before linking.
  • Make it simple to create a new post from the picker when no results match. You can add a “Create new post” button linking to post-new.php with ?post_title=encodedTitle.

Advanced integration tips

  • Integrating with core LinkControl: WordPress has components to manage linking (link UI, search, etc.). You can reuse or adapt core components if you need deep integration with core link UI. But often a custom, light suggester is simpler.
  • If you need to store relationships (e.g., track which posts link to which), set post meta or a custom table when the editor inserts a link. You can add REST calls to record link relationships after selection.
  • To exclude the current post from suggestions, add an exclude param to the REST call and implement it on server or client side with filtering. For /wp/v2/search there isnt a built-in exclude param, so use /wp/v2/posts with exclude=POST_ID if necessary.
  • Internationalization: ensure title strings used in UI are localized (use __() in PHP and i18n in JS).

Format of responses to expect

Consider these typical response shapes:

  • /wp/v2/search: [{ id: 123, title: Sample, url: https://example.com/sample, type: post, subtype: post }, …]
  • /wp/v2/posts?_fields=id,title,link,excerpt: [{ id: 123, title: { rendered: Title }, link: https…, excerpt: { rendered: … } }, …] — note nested rendered fields.
  • Custom endpoint (your output): return a clean object with primitive fields that are easy to consume by your JS client.

Example: Excluding the current post (client server)

If you want to exclude the currently edited post from suggestions, pass the current post ID to the endpoint and filter it out on the server for safety so a query cannot be manipulated by the client.

// Add an exclude arg to the registered routes args and use it when building the WP_Query
args => array(
    q => array( required => true, sanitize_callback => sanitize_text_field ),
    exclude => array( required => false, sanitize_callback => absint ),
    per_page => array( required => false, default => 10, sanitize_callback => absint ),
)
// Then in callback:
exclude = request->get_param( exclude )
args = array(
  s => q,
  post__not_in => exclude ? array( (int) exclude ) : array(),
  // ... other args
)

Testing and debugging

  • Use the browser Network tab to inspect the REST calls, their payloads, response times and errors.
  • Use WP-CLI or unit tests to validate server-side endpoint behavior and sanitization.
  • Log server-side queries and slow results in development to detect slow WP_Query usage on large sites.
  • Test with different permissions (logged-out users, editors, admins) to ensure permission boundaries are correct.

Complete checklist before shipping

  • Limit per_page and enforce it server-side.
  • Sanitize and validate all incoming parameters.
  • Ensure REST permission callback is appropriate for your content visibility requirements.
  • Debounce client requests and use AbortController to cancel stale requests.
  • Handle errors gracefully in the UI (show friendly messages and avoid blocking the editor).
  • Consider caching for frequent queries (transients or object cache).
  • Test keyboard accessibility and screen-reader semantics if creating a custom combobox UI.

Links and references

Closing notes

This tutorial gives you a full working path to implement internal link suggestion within the block editor using REST. Use the built-in /wp/v2/search endpoint for the simplest integration. Add a custom endpoint only when you need custom ranking or fields. Follow the security, performance and UX advice above to build a responsive, safe and useful editor experience for content authors.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *