Builders https://wpengine.com/builders/ Reimagining the way we build with WordPress. Thu, 12 Mar 2026 15:15:37 +0000 en-US hourly 1 https://wordpress.org/?v=6.9.1 https://wpengine.com/builders/wp-content/uploads/2024/05/wp-engine-favicon-32.png Builders https://wpengine.com/builders/ 32 32 Deploying a Composer-Based WordPress Site (Including Bedrock) to WP Engine https://wpengine.com/builders/deploying-composer-based-wordpress-site-to-wp-engine/ https://wpengine.com/builders/deploying-composer-based-wordpress-site-to-wp-engine/#respond Thu, 12 Mar 2026 13:24:33 +0000 https://wpengine.com/builders/?p=32109 Modern WordPress development has increasingly embraced Composer as a way to manage dependencies and improve workflow consistency. Whether you’re using a custom Composer setup or a framework like Bedrock, WP […]

The post Deploying a Composer-Based WordPress Site (Including Bedrock) to WP Engine appeared first on Builders.

]]>
Modern WordPress development has increasingly embraced Composer as a way to manage dependencies and improve workflow consistency. Whether you’re using a custom Composer setup or a framework like Bedrock, WP Engine supports deploying these projects—with a few adjustments.

This guide covers how to structure and deploy a Composer-based site to WP Engine using GitHub Actions, including Bedrock-specific notes.

Benefits of Using Composer for WordPress

Composer is a dependency management tool in PHP that offers several advantages for WordPress development:

  • Dependency Management: Easily manage WordPress core, plugins, themes, and PHP packages.
  • Version Control: Keep track of exact versions of all dependencies, ensuring consistency across environments.
  • Automation: Automate updates and deployments, reducing manual errors.
  • Cleaner Repositories: Avoid committing vendor files or plugin code, keeping your repository clean and focused on custom code.

By leveraging Composer, you can create a more maintainable and scalable WordPress project.

Managing Plugins and Themes with WPackagist

WPackagist mirrors the WordPress.org plugin and theme directories as Composer packages, allowing you to manage them as dependencies.

To use WPackagist:

1. Add WPackagist as a repository in your composer.json:

{
  "repositories": [
    {
      "type": "composer",
      "url": "https://wpackagist.org"
    }
  ]
}

2. Require plugins or themes using the wpackagist-plugin or wpackagist-theme prefixes:

{
  "require": {
    "wpackagist-plugin/advanced-custom-fields": "^6.0",
    "wpackagist-theme/twentytwentysix": "*"
  }
}

3. Run composer install to install the specified plugins and themes.

This approach ensures that your plugins and themes are version-controlled and can be easily updated or rolled back as needed.

Project Structure Considerations

Typical Composer-based sites move the WordPress core files to a different directory, for example /web or /public and with that the wp-content directory housing the plugins and themes will move there too. However, this is a different structure expected by WP Engine as a managed hosting platform. Bedrock also organizes WordPress files differently than the traditional structure expected by WP Engine.

Bedrock Structure:

├── composer.json
├── composer.lock
├── config/
├── web/
│   ├── app/            # wp-content equivalent
│   ├── wp/             # WordPress core
│   ├── index.php
│   └── wp-config.php
└── .env

WP Engine Expected Structure:

├── wp-content/
├── wp-includes/
├── wp-admin/
├── index.php
├── wp-config.php
...

To deploy a Composer or Bedrock-based site to WP Engine, you’ll need to restructure your project during the deployment process to match WP Engine’s expectations.

Deployment with GitHub Actions

Use WP Engine’s official GitHub Action to automate deployments. This approach syncs your wp-content/ directory and runs Composer during the build step.

Standard Composer Setup

For sites using Composer with a standard WordPress structure:

name: Deploy to WP Engine

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          tools: composer:v2

      - name: Install Composer dependencies
        run: composer install --no-dev --optimize-autoloader --prefer-dist

      - name: Deploy to WP Engine
        uses: wpengine/github-action-wpe-site-deploy@v3
        with:
          WPE_SSHG_KEY_PRIVATE: ${{ secrets.WPE_SSHG_KEY_PRIVATE }}
          WPE_ENV: your-environment-name
          SRC_PATH: wp-content/
          REMOTE_PATH: wp-content/
          FLAGS: -azvr --inplace --delete --exclude='uploads'

Including Composer Dependencies (Optional)

If your plugins or themes require PHP packages from vendor/, you’ll need to include it in the deployment. Since only wp-content/ is synced to WP Engine, copy vendor into wp-content/ and add a mu-plugin to load the autoloader:

      - name: Install Composer dependencies
        run: composer install --no-dev --optimize-autoloader --prefer-dist

      - name: Prepare vendor for deployment
        run: |
          mkdir -p wp-content/mu-plugins

          # Create autoloader mu-plugin
          cat > wp-content/mu-plugins/00-autoloader.php << 'EOF'
          <?php
          /**
           * Plugin Name: Composer Autoloader
           * Description: Loads the Composer autoloader.
           */
          if (file_exists($autoloader = dirname(__DIR__) . '/vendor/autoload.php')) {
              require_once $autoloader;
          }
          EOF

          # Copy vendor into wp-content for deployment
          cp -r vendor wp-content/

      - name: Deploy to WP Engine
        ...

Composer’s autoloader uses relative paths, so it works correctly from wp-content/vendor/.

Bedrock Setup

For Bedrock projects, you’ll need to restructure from web/app/ to wp-content/ during the build.

Bedrock normally loads the Composer autoloader via its wp-config.php. Since WP Engine manages wp-config.php, the workflow below creates a mu-plugin to load it instead. The 00- prefix ensures it loads before other mu-plugins.

name: Deploy Bedrock to WP Engine

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.2'
          tools: composer:v2

      - name: Install Composer dependencies
        run: composer install --no-dev --optimize-autoloader --prefer-dist

      - name: Restructure for WP Engine
        run: |
          mkdir -p deploy/wp-content/mu-plugins

          # Create autoloader mu-plugin (WP Engine manages wp-config.php, so we load it here)
          cat > deploy/wp-content/mu-plugins/00-autoloader.php << 'EOF'
          <?php
          /**
           * Plugin Name: Composer Autoloader
           * Description: Loads the Composer autoloader for WP Engine deployments.
           */
          if (file_exists($autoloader = dirname(__DIR__) . '/vendor/autoload.php')) {
              require_once $autoloader;
          }
          EOF

          # Move app (wp-content equivalent) contents
          cp -r web/app/plugins deploy/wp-content/
          cp -r web/app/themes deploy/wp-content/
          cp -r web/app/mu-plugins/* deploy/wp-content/mu-plugins/ 2>/dev/null || true
          cp -r web/app/languages deploy/wp-content/ 2>/dev/null || true

          # Move vendor for Composer autoloading
          cp -r vendor deploy/wp-content/

      - name: Deploy to WP Engine
        uses: wpengine/github-action-wpe-site-deploy@v3
        with:
          WPE_SSHG_KEY_PRIVATE: ${{ secrets.WPE_SSHG_KEY_PRIVATE }}
          WPE_ENV: your-environment-name
          SRC_PATH: deploy/wp-content/
          REMOTE_PATH: wp-content/
          FLAGS: -azvr --inplace --delete --exclude='uploads'

Setup Requirements

  1. SSH Key: Add your WP Engine SSH private key as a repository secret named WPE_SSHG_KEY_PRIVATE. Generate via WP Engine User Portal > SSH Keys.
  1. Environment Name: Replace your-environment-name with your WP Engine environment name.
  1. Branch Triggers: Adjust the branches trigger as needed (e.g., main for production, staging for staging).

Important Notes

  • WordPress core: WP Engine manages core files. Only deploy your wp-content/ or equivalent.
  • Uploads: Media uploads are not included in deployments. Use SFTP or WP Engine’s backup/restore tools.
  • Vendor directory: Required for Bedrock to enable the mu-plugin directory loader and any Composer dependencies. Include the 00-autoloader.php mu-plugin to load it.
  • Environment variables: Bedrock uses .env files. Ensure environment-specific config is handled appropriately for WP Engine.
  • First deploy: After your first deployment, log in to WordPress and activate your theme. The deploy uses --delete which removes the default theme, so you’ll need to activate the theme you deployed.

Further Reading

The post Deploying a Composer-Based WordPress Site (Including Bedrock) to WP Engine appeared first on Builders.

]]>
https://wpengine.com/builders/deploying-composer-based-wordpress-site-to-wp-engine/feed/ 0
Customizing WP Engine’s Newsroom Publication Checklist https://wpengine.com/builders/customizing-wp-engines-newsroom-publication-checklist/ https://wpengine.com/builders/customizing-wp-engines-newsroom-publication-checklist/#respond Mon, 16 Feb 2026 15:06:07 +0000 https://wpengine.com/builders/?p=32067 In the publisher workflow, checklists are important to ensure that you don’t miss anything. Imagine how messy or unorganized things can get without checklists in your everyday life, let alone […]

The post Customizing WP Engine’s Newsroom Publication Checklist appeared first on Builders.

]]>
In the publisher workflow, checklists are important to ensure that you don’t miss anything. Imagine how messy or unorganized things can get without checklists in your everyday life, let alone content you are writing for work.

In WordPress, WP Engine’s Newsroom features the Publication Checklist. It is a customizable quality-control tool that ensures all editorial standards are met before a story goes live. 

The publication checklist helps you move away from time-consuming administrative content checks. Completely configurable to your requirements, the checklist flags missing items in real-time to editors before publishing, and gives customized suggestions to make sure your content meets everything you need it to.

In this article, I will walk you through how to customize and use these features as well as integrate them with a work management software. In this example, I will use Asana®¹. We will cover these points:

  • JSON config customized to your editorial guidelines
  • Custom Fields to render the custom guideline
  • Create blocking checks that stop publishing
  • Visual indicator of progress

Just a Note: If you want a full explanation of all the features and default settings you can choose, please refer to the official documentation. This article will focus on the Publication Checklist feature and customization.

If you prefer a video version of this article, please reference it below:

Prerequisites

To benefit from this article, you should be familiar with the basics of working with the command line (WP-CLI) and WordPress development.

Steps for setting up:

1.Either set up a WordPress install on WP Engine or spin one up locally using Local. For this article, I am using Local.

2.Add a Newsroom license. Once you get a Newsroom license, you will have access to the code and repository.

3.Download the latest version of the plugin off the core repository once you have access to it. Upload the plugin into your WordPress install. You should now see a MediaPress option in your WP Admin side menu like this:

 4. The next plugin you will need is the simple example extension I created for the Asana connection. We will need to add the plugin extension to our WordPress install. Grab the code here from this repo. Clone the repo directly into your /wp-content/plugins/ directory. It lives in the  mediapress-simple folder.  Once you have this added to your WP install, You should see the plugin within the dashboard. You can go ahead and activate it.

Now that we have both plugins needed, we need to make sure our settings are properly configured.

If you are accessing this for the first time, you will see the setup wizard allowing you to enable your required features, including the checklist. Once enabled you will have access to toggle the Demo Config option on or off.

In the WP admin sidebar you will see the MediaPress option. Select Checklist and make sure that the Use Demo Config option is toggled off. This will allow you to use the simple example instead.

We are all set up! Now, let’s dive into the customization.

The checklist.json File

Let’s take a look at the JSON file for the Publication Checklist. Go to mediapress-simple/config/checklist.json in your project. You should see a structure that defines an array of checklist items, where each item is a small rule that MediaPress evaluates against a post. Each item includes:

  • A stable name identifier
  • A human-readable title
  • An item type (blocking, non_blocking, or info)
  • An optional check block describing the validation operator (exists/min/max/range)
  • Messages shown in the UI for pass/fail/info states
  • PostTypes to scope the rule

Refer to the link here for the JSON file in the GitHub repo.

The publication checklist is entirely configuration-driven. This allows customisation based on specific requirements. To define additional checklist items, simply  add a new rule in the JSON schema. MediaPress takes care of validation, UI rendering, and blocking behavior. In this example, two new rules have been added: a rule for legal review checks and a featured image check before publishing.

Customizing The JSON Files

The first thing we have to do is add the two rules directly to our checklist.json file. I went ahead and added them:

{
      "name": "has_featured_image",
      "title": "Has featured image",
      "type": "non_blocking",
      "check": {
        "type": "exists",
        "sourceKey": "meta._thumbnail_id"
      },
      "messages": {
        "pass": "Featured image is set",
        "fail": "Featured image is missing"
      },
      "postTypes": ["post"]
    },
    {
      "name": "legal_review_complete",
      "title": "Legal review completed",
      "type": "blocking",
      "check": {
        "type": "exists",
        "sourceKey": "meta.legal_review"
      },
      "messages": {
        "pass": "Legal review completed",
        "fail": "Legal review is required before publishing"
      },
      "postTypes": ["post"]
    }

Here is the breakdown of the rules I added:

1. has_featured_image (Non-blocking)

  • Checks: WordPress featured image via meta._thumbnail_id.
  • Type: Non-blocking (won’t prevent publishing).
  • Validation: “exists” check, passes if the post has a featured image set.
  • Asana behavior: Will create a task that marks as complete when the featured image is added.

2. legal_review_complete (Blocking)

  • Checks: Custom meta field meta.legal_review.
  • Type: Blocking (prevents publishing if incomplete).
  • Validation: “exists” check, passes if the legal_review meta field has a value.
  • Asana behavior: Will create a task that marks complete when the legal review meta is set.

Both rules follow the correct structure and will integrate with the Asana sync automatically. The featured image check uses WordPress’s native _thumbnail_id meta field, while the legal review uses a custom meta field. Once you add your custom rules, the config is loaded dynamically each time, so changes take effect immediately on the next post save.

To access the publication checklist, go to a post and click on the checklist icon with the red dot at the top of the page in the nav. The red dot lets you know that there are tasks yet to be done on the list. You should see this:

The fields.json File

While checklist.json defines the rules, fields.json defines the interface and data structure for the custom metadata your editors interact with. In this project, we use this file to register the “Legal Review” checkbox and position it within the MediaPress fields menu under the “status” option.

Go to mediapress-simple/config/fields.json in your project. Here is the configuration for the new field:

{
    "name": "legal_review",
    "type": "checkbox",
    "label": "Legal Review",
    "description": "Legal review status for this post",
    "source": {
      "type": "postMeta",
      "key": "legal_review",
      "registerFor": ["post"]
    },
    "options": [
      {
        "label": "Legal review completed",
        "value": "completed"
      }
    ]
  }

This configuration handles the underlying data logic for your new check. It includes:

  • name: The internal identifier for the field.
  • type: Defines the UI component (in this case, a checkbox).
  • source: Instructs MediaPress to save this data as postMeta using the key legal_review.
  • registerFor: Limits this field specifically to the post post type.

Adding to the Status field group

Defining the field is only the first half of the process. To make it visible to your editorial team, you must assign it to a field group. In the same fields.json file, we update the Status group:

"fields": ["flag", "legal_review"]

By adding legal_review to this array, the checkbox appears in the WordPress Admin post editor sidebar under the Status section.

When you navigate to the status option, you should now see a legal review checkbox. You can test it and it should allow you to check and uncheck it. This will reflect in the checklist as done or not done:

Connecting With Asana

In this article, I will use Asana. I chose it because their API is simple to use and they have a free trial offer for the starter plan. However, please feel free to use whatever project management software your use case calls for.

Asana API and Personal Access Token

The first thing we need to do is grab the API and an access token from your Asana account. Visit the Asana Developer Console to generate a new token. Save that token because we will need it for our connection to WordPress. The Asana API base URL is https://app.asana.com/api/1.0.

To find your project ID, follow these steps:

  1. Open your Asana project in your web browser.
  2. Look at the URL, it will look something like: https://app.asana.com/0/1234567890123456/board.
  3. The Project ID is the long number after the /0/, in this example: 1234567890123456.

Save these credentials. We will need them in the next section.

Configure Your Asana Credentials

Under the hood, this demo reads from WordPress options using get_option(). You can set these using WP-CLI.  Make sure you are at the root of your WP install before setting these on your CLI:

wp option update mpc_asana_access_token "YOUR_ASANA_PAT"
wp option update mpc_asana_project_id "YOUR_PROJECT_GID"

Once set, you should be able to confirm the options exist using wp option get.

Now test it. Save or update a post, and tasks should appear in your Asana project within a few seconds. The legal review custom checklist item when you check and uncheck should also reflect after you save it in Asana as well.

The class-asana-client.php File

Now, let’s dive into the code to show how this works. The first file we will go over is at mediapress-simple/inc/class-asana-client.php. The file is long so let’s break it down in chunks starting from the top.

<?php
/**
 * Asana API Client
 * Handles communication with Asana API
 */
class MPC_Asana_Client {

	/**
	 * Asana API base URL
	 */
	const API_BASE = 'https://app.asana.com/api/1.0';

	/**
	 * Personal Access Token
	 *
	 * @var string
	 */
	private $access_token;

This opening chunk sets up the client class and establishes two core pieces of state. API_BASE is the fixed root for every Asana REST call, so you only ever append endpoints like /tasks to it. $access_token stores the Personal Access Token you’ll use to authenticate every request via the Authorization: Bearer … header.

/**
	 * Constructor
	 *
	 * @param string $access_token Asana Personal Access Token.
	 */
	public function __construct( $access_token ) {
		$this->access_token = $access_token;
	}

The constructor is intentionally minimal: it accepts your Asana token once and keeps it on the instance. 

/**
	 * Create a task in Asana
	 */
	public function create_task( $project_id, $name, $notes = '' ) {
		$data = array(
			'data' => array(
				'name'     => $name,
				'notes'    => $notes,
				'projects' => array( $project_id ),
			),
		);

		return $this->request( 'POST', '/tasks', $data );
	}

create_task() is our operation for the demo: it builds the payload in the shape Asana expects (data: { … }), assigns the task to a project, and sends it to POST /tasks

Everything funnels through request().

	/**
	 * Update a task in Asana
	 */
	public function update_task( $task_id, $data ) {
		$payload = array(
			'data' => $data,
		);

		return $this->request( 'PUT', "/tasks/{$task_id}", $payload );
	}

update_task() is the generic “change anything” method. You pass an array of fields you want to update (for example completed, notes, due_on, etc.), and it wraps that inside the required data envelope. This becomes the foundation for higher-level helpers like “complete” and “incomplete.”

/**
	 * Mark a task as complete
	 */
	public function complete_task( $task_id ) {
		return $this->update_task( $task_id, array( 'completed' => true ) );
	}

	/**
	 * Mark a task as incomplete
	 */
	public function incomplete_task( $task_id ) {
		return $this->update_task( $task_id, array( 'completed' => false ) );
	}

These are methods for completion state that we centralize. This maps to a checklist workflow: a rule passing can “complete” a task, and a rule failing can “re-open” it.

/**
	 * Get a task from Asana
	 */
	public function get_task( $task_id ) {
		return $this->request( 'GET', "/tasks/{$task_id}" );
	}

	/**
	 * Delete a task from Asana
	 */
	public function delete_task( $task_id ) {
		return $this->request( 'DELETE', "/tasks/{$task_id}" );
	}

These are wrappers for reading and deleting tasks. We use them for debugging, verification, or cleanup.

/**
	 * Make an API request to Asana
	 */
	private function request( $method, $endpoint, $data = array() ) {
		$url = self::API_BASE . $endpoint;

		$args = array(
			'method'  => $method,
			'headers' => array(
				'Authorization' => 'Bearer ' . $this->access_token,
				'Content-Type'  => 'application/json',
				'Accept'        => 'application/json',
			),
			'timeout' => 30,
		);

		if ( ! empty( $data ) && in_array( $method, array( 'POST', 'PUT' ), true ) ) {
			$args['body'] = wp_json_encode( $data );
		}

		$response = wp_remote_request( $url, $args );

This is the HTTP layer. It composes the request URL, attaches the authorization header, declares JSON input/output, and uses WordPress’s HTTP API (wp_remote_request) so it works across hosts and environments.

It also only includes a JSON body when it is actually needed (POST/PUT), which prevents accidental body payloads on GET requests.

if ( is_wp_error( $response ) ) {
			return $response;
		}

		$status_code = wp_remote_retrieve_response_code( $response );
		$body        = wp_remote_retrieve_body( $response );
		$headers     = wp_remote_retrieve_headers( $response );

		// Handle rate limiting (429)
		if ( 429 === $status_code ) {
			$retry_after = isset( $headers['Retry-After'] ) ? (int) $headers['Retry-After'] : 60;
			return new WP_Error(
				'asana_rate_limit',
				sprintf( 'Rate limit exceeded. Retry after %d seconds.', $retry_after ),
				array(
					'status'      => $status_code,
					'retry_after' => $retry_after,
				)
			);
		}

We handle our transport failures and Asana®¹ throttling in this file. If WordPress itself cannot make the request, you return the WP_Error as-is. If Asana responds with a 429, you read the Retry-After header (when available) and return a structured error that higher-level code can use to decide whether to retry later.

// Decode JSON response
		$decoded = json_decode( $body, true );

		// Handle non-JSON responses
		if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
			return new WP_Error(
				'asana_invalid_response',
				'Invalid JSON response from Asana API',
				array(
					'status' => $status_code,
					'body'   => substr( $body, 0, 500 ),
				)
			);
		}


This section protects you from unexpected responses. Asana should return JSON, but if anything upstream returns HTML, an empty body, or a proxy injects content, you will fail safely with a clear error. Trimming the body to the first 500 characters gives you enough to debug without dumping huge responses.

// Handle error responses
		if ( $status_code >= 400 ) {
			$error_message = isset( $decoded['errors'][0]['message'] )
				? $decoded['errors'][0]['message']
				: 'Unknown Asana API error';

			return new WP_Error(
				'asana_api_error',
				$error_message,
				array(
					'status' => $status_code,
					'body'   => $decoded,
				)
			);
		}

		return $decoded;
	}
}

Finally, you normalize any Asana error (400+) into a WP_Error with the most helpful message available, plus the status code and decoded payload for debugging. If everything succeeds, you return the decoded JSON array, which keeps the rest of your integration code simple: it can just read ['data']['gid'] or other Asana fields directly.

You can view the code in it’s entirety here:
<?php
/**
 * Asana API Client
 * Handles communication with Asana API
 */
class MPC_Asana_Client {

	/**
	 * Asana API base URL
	 */
	const API_BASE = 'https://app.asana.com/api/1.0';

	/**
	 * Personal Access Token
	 *
	 * @var string
	 */
	private $access_token;

	/**
	 * Constructor
	 *
	 * @param string $access_token Asana Personal Access Token.
	 */
	public function __construct( $access_token ) {
		$this->access_token = $access_token;
	}

	/**
	 * Create a task in Asana
	 *
	 * @param string $project_id Asana project ID.
	 * @param string $name Task name.
	 * @param string $notes Task description/notes.
	 * @return array|WP_Error Task data or error.
	 */
	public function create_task( $project_id, $name, $notes = '' ) {
		$data = array(
			'data' => array(
				'name'     => $name,
				'notes'    => $notes,
				'projects' => array( $project_id ),
			),
		);

		return $this->request( 'POST', '/tasks', $data );
	}

	/**
	 * Update a task in Asana
	 *
	 * @param string $task_id Asana task ID.
	 * @param array  $data Task data to update.
	 * @return array|WP_Error Updated task data or error.
	 */
	public function update_task( $task_id, $data ) {
		$payload = array(
			'data' => $data,
		);

		return $this->request( 'PUT', "/tasks/{$task_id}", $payload );
	}

	/**
	 * Mark a task as complete
	 *
	 * @param string $task_id Asana task ID.
	 * @return array|WP_Error Updated task data or error.
	 */
	public function complete_task( $task_id ) {
		return $this->update_task( $task_id, array( 'completed' => true ) );
	}

	/**
	 * Mark a task as incomplete
	 *
	 * @param string $task_id Asana task ID.
	 * @return array|WP_Error Updated task data or error.
	 */
	public function incomplete_task( $task_id ) {
		return $this->update_task( $task_id, array( 'completed' => false ) );
	}

	/**
	 * Get a task from Asana
	 *
	 * @param string $task_id Asana task ID.
	 * @return array|WP_Error Task data or error.
	 */
	public function get_task( $task_id ) {
		return $this->request( 'GET', "/tasks/{$task_id}" );
	}

	/**
	 * Delete a task from Asana
	 *
	 * @param string $task_id Asana task ID.
	 * @return array|WP_Error Response or error.
	 */
	public function delete_task( $task_id ) {
		return $this->request( 'DELETE', "/tasks/{$task_id}" );
	}

	/**
	 * Make an API request to Asana
	 *
	 * @param string $method HTTP method (GET, POST, PUT, DELETE).
	 * @param string $endpoint API endpoint path.
	 * @param array  $data Request data.
	 * @return array|WP_Error Response data or error.
	 */
	private function request( $method, $endpoint, $data = array() ) {
		$url = self::API_BASE . $endpoint;

		$args = array(
			'method'  => $method,
			'headers' => array(
				'Authorization' => 'Bearer ' . $this->access_token,
				'Content-Type'  => 'application/json',
				'Accept'        => 'application/json',
			),
			'timeout' => 30,
		);

		if ( ! empty( $data ) && in_array( $method, array( 'POST', 'PUT' ), true ) ) {
			$args['body'] = wp_json_encode( $data );
		}

		$response = wp_remote_request( $url, $args );

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$status_code = wp_remote_retrieve_response_code( $response );
		$body        = wp_remote_retrieve_body( $response );
		$headers     = wp_remote_retrieve_headers( $response );

		// Handle rate limiting (429)
		if ( 429 === $status_code ) {
			$retry_after = isset( $headers['Retry-After'] ) ? (int) $headers['Retry-After'] : 60;
			return new WP_Error(
				'asana_rate_limit',
				sprintf( 'Rate limit exceeded. Retry after %d seconds.', $retry_after ),
				array(
					'status'      => $status_code,
					'retry_after' => $retry_after,
				)
			);
		}

		// Decode JSON response
		$decoded = json_decode( $body, true );

		// Handle non-JSON responses
		if ( null === $decoded && JSON_ERROR_NONE !== json_last_error() ) {
			return new WP_Error(
				'asana_invalid_response',
				'Invalid JSON response from Asana API',
				array(
					'status' => $status_code,
					'body'   => substr( $body, 0, 500 ), // First 500 chars for debugging
				)
			);
		}

		// Handle error responses
		if ( $status_code >= 400 ) {
			$error_message = isset( $decoded['errors'][0]['message'] )
				? $decoded['errors'][0]['message']
				: 'Unknown Asana API error';

			return new WP_Error(
				'asana_api_error',
				$error_message,
				array(
					'status' => $status_code,
					'body'   => $decoded,
				)
			);
		}

		return $decoded;
	}
}


The class-asana-integration.php File

The second file that you need to add to your plugin to make this work is at mediapress-simple/inc/class-asana-integration.php.

Breaking the file down starting from the top:

<?php
/**
 * Asana Integration
 * Syncs MediaPress checklist items with Asana tasks (optimized)
 */
class MPC_Asana_Integration {

	/**
	 * Asana API client
	 *
	 * @var MPC_Asana_Client
	 */
	private $client;

	/**
	 * Asana Project ID
	 *
	 * @var string
	 */
	private $project_id;

	/**
	 * Constructor
	 */
	public function __construct() {
		// Get settings
		$access_token     = get_option( 'mpc_asana_access_token', '' );
		$this->project_id = get_option( 'mpc_asana_project_id', '' );

		// Only initialize if credentials are set
		if ( empty( $access_token ) || empty( $this->project_id ) ) {
			return;
		}

		// Initialize Asana client
		$this->client = new MPC_Asana_Client( $access_token );

		// Hook into post save (canonical trigger - handles both block and classic editor)
		add_action( 'save_post', array( $this, 'schedule_sync' ), 20, 2 );

		// Hook for background processing via WP-Cron
		add_action( 'mpc_asana_background_sync', array( $this, 'sync_checklist_to_asana' ), 10, 1 );
	}

At the top of this file, we read the Asana®¹ credentials from WordPress®¹ options, and bail early if they are not set, which keeps the plugin from doing work until it is properly configured.

Once the token and project ID exist, we initialize our MPC_Asana_Client and hook into one canonical WordPress event (save_post) to detect when a post changes. From there, we have a background event (mpc_asana_background_sync) so we are not calling Asana during the editor save request.

/**
	 * Schedule async sync (debounced to prevent duplicates)
	 * Uses WP-Cron for true non-blocking background processing
	 */
	public function schedule_sync( $post_id, $post ) {
		// Skip autosaves and revisions
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		// Only process posts (can be expanded to other post types)
		if ( 'post' !== $post->post_type ) {
			return;
		}

		// Debounce: Check if we already scheduled a sync for this post recently
		$transient_key = 'mpc_asana_sync_scheduled_' . $post_id;
		if ( get_transient( $transient_key ) ) {
			// Already scheduled within last 5 seconds, skip
			return;
		}

		// Set transient to prevent duplicate scheduling (expires in 5 seconds)
		set_transient( $transient_key, true, 5 );

		// Schedule WP-Cron event for background processing (runs in ~5 seconds)
		wp_schedule_single_event( time() + 5, 'mpc_asana_background_sync', array( $post_id ) );
	}

This next code block prevents extra sync behavior by ignoring autosaves and revisions, and it scopes the integration to the post post type so you do not accidentally sync everything in the CMS.

The transient acts as a debounce so we do not schedule five sync jobs when the block editor (Gutenberg) triggers multiple saves in quick succession. Finally, it schedules a single WP-Cron event to do the Asana work after the editor request finishes.

/**
	 * Sync checklist items to Asana (runs in background)
	 */
	public function sync_checklist_to_asana( $post_id ) {
		// Acquire sync lock to prevent concurrent syncs
		$lock_key = 'mpc_asana_sync_lock_' . $post_id;
		if ( get_transient( $lock_key ) ) {
			// Another sync is already in progress, skip
			return;
		}

		// Set lock for 30 seconds
		set_transient( $lock_key, true, 30 );

		$post = get_post( $post_id );

		if ( ! $post || 'post' !== $post->post_type ) {
			delete_transient( $lock_key );
			return;
		}

		// Get checklist configuration
		$checklist_items = $this->get_checklist_items( $post->post_type );

		if ( empty( $checklist_items ) ) {
			delete_transient( $lock_key );
			return;
		}

		// Get existing task mappings
		$task_mappings = get_post_meta( $post_id, '_mpc_asana_task_mappings', true );
		if ( ! is_array( $task_mappings ) ) {
			$task_mappings = array();
		}

		// Get previous validation states
		$previous_states = get_post_meta( $post_id, '_mpc_asana_validation_states', true );
		if ( ! is_array( $previous_states ) ) {
			$previous_states = array();
		}

		$updated_states  = array();
		$tasks_to_update = array();

This next block starts by making sure we do not run two syncs at the same time for the same post.

The transient lock is the concurrency control and it prevents duplicate task creation under load.

Next, we load the post, load the checklist rules for that post type, and then hydrate two pieces of state from post meta: (1) which checklist items already have Asana task IDs, and (2) what the last known validation result was for each item.

That stored state is what allows you to be efficient and only touch Asana when something actually changed.

// Process each checklist item
		foreach ( $checklist_items as $item ) {
			// Skip info items (items without validation checks)
			if ( ! isset( $item['check'] ) ) {
				continue;
			}

			$item_name = $item['name'];

			// Check if we already have an Asana task for this item
			if ( ! isset( $task_mappings[ $item_name ] ) ) {
				// Create new task
				$task_id = $this->create_asana_task( $post_id, $post, $item );
				if ( $task_id ) {
					$task_mappings[ $item_name ] = $task_id;
					// New task, validate and update
					$is_valid                     = $this->validate_checklist_item( $post_id, $post, $item );
					$updated_states[ $item_name ] = $is_valid;
					$tasks_to_update[]            = array(
						'task_id'  => $task_id,
						'is_valid' => $is_valid,
					);
				}
			} else {
				// Validate current state
				$is_valid                     = $this->validate_checklist_item( $post_id, $post, $item );
				$updated_states[ $item_name ] = $is_valid;

				// Only update if state changed
				$previous_state = isset( $previous_states[ $item_name ] ) ? $previous_states[ $item_name ] : null;
				if ( $previous_state !== $is_valid ) {
					$tasks_to_update[] = array(
						'task_id'  => $task_mappings[ $item_name ],
						'is_valid' => $is_valid,
					);
				}
			}
		}

This loop is where the checklist focuses on tasks. We skip purely informational checklist entries (no check), because those are not really pass/fail requirements.

For each rule that can be validated, you either create the task once (if it does not exist yet) or re-check the rule and compare it to the last recorded state. The key optimization is that you only queue an Asana update when the rule flips from pass to fail or fail to pass, so you avoid hammering the API on every save.

// Batch update only changed tasks
		if ( ! empty( $tasks_to_update ) ) {
			foreach ( $tasks_to_update as $task_update ) {
				if ( $task_update['is_valid'] ) {
					$this->client->complete_task( $task_update['task_id'] );
				} else {
					$this->client->incomplete_task( $task_update['task_id'] );
				}
			}
		}

		// Save updated mappings and states
		update_post_meta( $post_id, '_mpc_asana_task_mappings', $task_mappings );
		update_post_meta( $post_id, '_mpc_asana_validation_states', $updated_states );

		// Release lock
		delete_transient( $lock_key );
	}

This section of code applies changes to Asana and then persists the new truth back to WordPress. If a rule is valid, the matching task gets completed; if it becomes invalid again, the task is reopened.

After that, you update the post meta mapping and the validation state cache so the next sync can be incremental rather than starting from scratch. Finally, you release the transient lock so future saves can trigger another sync.



	/**
	 * Create an Asana task for a checklist item
	 */
	private function create_asana_task( $post_id, $post, $item ) {
		// Sanitize and truncate post title for Asana (max 1024 chars)
		$post_title = $post->post_title ? wp_strip_all_tags( $post->post_title ) : 'Untitled Post';
		$post_title = mb_substr( $post_title, 0, 100 ); // Limit to 100 chars for task name

		// Sanitize item title
		$item_title = isset( $item['title'] ) ? wp_strip_all_tags( $item['title'] ) : 'Checklist Item';

		$task_name = sprintf(
			'[%s] %s',
			$post_title,
			$item_title
		);

		// Truncate full task name if too long (Asana limit is 1024)
		$task_name = mb_substr( $task_name, 0, 1000 );

		$task_notes = sprintf(
			"Post: %s\nPost ID: %d\nChecklist Item: %s\nType: %s",
			get_permalink( $post_id ),
			$post_id,
			isset( $item['name'] ) ? sanitize_text_field( $item['name'] ) : '',
			isset( $item['type'] ) ? sanitize_text_field( $item['type'] ) : ''
		);

		$result = $this->client->create_task( $this->project_id, $task_name, $task_notes );

		if ( is_wp_error( $result ) ) {
			error_log( 'Asana task creation failed: ' . $result->get_error_message() );
			return false;
		}

		return isset( $result['data']['gid'] ) ? $result['data']['gid'] : false;
	}

This helper creates a task that is readable in Asana and safe to generate from WordPress content. We sanitize and truncate the post title so it does not pollute your task list with HTML or overly long names.

The task name format ([Post Title] Checklist Item) makes it obvious what the task belongs to, and the notes include a direct permalink back to the WordPress post so editors can jump back to fix the issue quickly. If Asana rejects the request, you log the error and fail gracefully; otherwise you return the new task GID so it can be stored and reused.

/**
	 * Validate a checklist item against post data
	 *
	 * @param int      $post_id Post ID.
	 * @param WP_Post $post Post object.
	 * @param array    $item Checklist item configuration.
	 * @return bool True if item passes validation, false otherwise.
	 */
	private function validate_checklist_item( $post_id, $post, $item ) {
		// Item must have a check field (caller should verify this)
		if ( ! isset( $item['check'] ) ) {
			return false;
		}

		$check      = $item['check'];
		$check_type = isset( $check['type'] ) ? $check['type'] : '';
		$source_key = isset( $check['sourceKey'] ) ? $check['sourceKey'] : '';

		// Get the value to check
		$value = $this->get_post_value( $post_id, $post, $source_key );

		// Perform validation based on check type
		switch ( $check_type ) {
			case 'exists':

				// Handle checkbox arrays (like legal_review which saves as array)
				if ( is_array( $value ) ) {
					// For legal_review, check if 'completed' is in the array
					if ( 'meta.legal_review' === $source_key ) {
						return in_array( 'completed', $value, true );
					}
					// For other arrays, filter out empty strings and check if array has values
					$value = array_filter( $value );
					return ! empty( $value );
				}
				// For strings, empty string should fail
				if ( is_string( $value ) ) {
					return '' !== trim( $value );
				}
				return ! empty( $value );

			case 'min':
				if ( is_string( $value ) ) {
					$min = isset( $check['min'] ) ? (int) $check['min'] : 0;
					return strlen( $value ) >= $min;
				}
				return false;

			case 'max':
				if ( is_string( $value ) ) {
					$max = isset( $check['max'] ) ? (int) $check['max'] : PHP_INT_MAX;
					return strlen( $value ) <= $max;
				}
				return false;

			case 'range':
				if ( is_string( $value ) ) {
					$min    = isset( $check['min'] ) ? (int) $check['min'] : 0;
					$max    = isset( $check['max'] ) ? (int) $check['max'] : PHP_INT_MAX;
					$length = strlen( $value );
					return $length >= $min && $length <= $max;
				}
				return false;

			default:
				return false;
		}
	}

This function takes one checklist item definition and evaluates it against the current post data. The sourceKey tells you what to read (title, excerpt, meta fields, etc.), and check.type tells you how to validate it.

The exists validation is set to handle checkbox arrays. For our legal_review field, it specifically checks if the array contains ‘completed’. This ensures the Publication Checklist accurately reflects the checkbox state in the WordPress dashboard and syncs properly with Asana®¹. For standard strings, it now ensures that a simple empty string or whitespace will fail the validation.

Right now, we support the core operators used in our JSON (exists, min, max, and range), which covers the most common editorial checks like “required field” and “SEO length.” The output is a boolean that drives whether the Asana task should be open or completed.

/**
	 * Get a value from post data using dot notation
	 */
	private function get_post_value( $post_id, $post, $source_key ) {
		if ( empty( $source_key ) ) {
			return null;
		}

		// Handle meta fields
		if ( strpos( $source_key, 'meta.' ) === 0 ) {
			$meta_key = substr( $source_key, 5 );
			return get_post_meta( $post_id, $meta_key, true );
		}

		// Handle post fields
		switch ( $source_key ) {
			case 'title':
				return $post->post_title;

			case 'excerpt':
				return $post->post_excerpt;

			case 'content':
				return $post->post_content;

			case 'categories':
				$categories = get_the_category( $post_id );
				return ! empty( $categories );

			case 'tags':
				$tags = get_the_tags( $post_id );
				return ! empty( $tags );

			case 'authors':
				// Check for co-authors or author taxonomy
				$authors = get_the_terms( $post_id, 'authors' );
				if ( empty( $authors ) ) {
					// Fallback to post author
					return ! empty( $post->post_author );
				}
				return ! empty( $authors );

			default:
				return null;
		}
	}

This block is the bridge between the checklist schema and WordPress. It supports dot notation for meta (meta.some_key) and maps source keys like title, excerpt, categories, and authors to the WordPress functions that can answer those questions. The key idea is that the checklist JSON stays declarative, while this function translates it into real WordPress data lookups.

/**
	 * Get checklist items from configuration
	 */
	private function get_checklist_items( $post_type ) {
		// Get config path from filter
		$config_path = apply_filters( 'mediapress_checklist_config_path', '' );

		if ( empty( $config_path ) || ! file_exists( $config_path ) ) {
			return array();
		}

		// Read and parse config
		$config_content = file_get_contents( $config_path );
		$config         = json_decode( $config_content, true );

		if ( ! isset( $config['items'] ) || ! is_array( $config['items'] ) ) {
			return array();
		}

		// Filter items by post type
		$items = array();
		foreach ( $config['items'] as $item ) {
			if ( isset( $item['postTypes'] ) && in_array( $post_type, $item['postTypes'], true ) ) {
				$items[] = $item;
			}
		}

		return $items;
	}
}

This final section loads the checklist rules straight from the MediaPress configuration file, using the mediapress_checklist_config_path filter to discover where that JSON lives.

After decoding the file, it filters the items array down to just the rules that apply to the current post type, which keeps your integration flexible if you later want different checklists for different content types. The result is a clean list of rules the sync engine can evaluate and mirror into Asana.

You can view the code in it’s entirety here:
<?php
/**
 * Asana Integration
 * Syncs MediaPress checklist items with Asana tasks (optimized)
 */
class MPC_Asana_Integration {

	/**
	 * Asana API client
	 *
	 * @var MPC_Asana_Client
	 */
	private $client;

	/**
	 * Asana Project ID
	 *
	 * @var string
	 */
	private $project_id;

	/**
	 * Constructor
	 */
	public function __construct() {
		// Get settings
		$access_token     = get_option( 'mpc_asana_access_token', '' );
		$this->project_id = get_option( 'mpc_asana_project_id', '' );

		// Only initialize if credentials are set
		if ( empty( $access_token ) || empty( $this->project_id ) ) {
			return;
		}

		// Initialize Asana client
		$this->client = new MPC_Asana_Client( $access_token );

		// Hook into post save (canonical trigger - handles both block and classic editor)
		add_action( 'save_post', array( $this, 'schedule_sync' ), 20, 2 );

		// Hook for background processing via WP-Cron
		add_action( 'mpc_asana_background_sync', array( $this, 'sync_checklist_to_asana' ), 10, 1 );
	}

	/**
	 * Schedule async sync (debounced to prevent duplicates)
	 * Uses WP-Cron for true non-blocking background processing
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post Post object.
	 */
	public function schedule_sync( $post_id, $post ) {
		// Skip autosaves and revisions
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		// Only process posts (can be expanded to other post types)
		if ( 'post' !== $post->post_type ) {
			return;
		}

		// Debounce: Check if we already scheduled a sync for this post recently
		$transient_key = 'mpc_asana_sync_scheduled_' . $post_id;
		if ( get_transient( $transient_key ) ) {
			// Already scheduled within last 5 seconds, skip
			return;
		}

		// Set transient to prevent duplicate scheduling (expires in 5 seconds)
		set_transient( $transient_key, true, 5 );

		// Schedule WP-Cron event for background processing (runs in ~5 seconds)
		wp_schedule_single_event( time() + 5, 'mpc_asana_background_sync', array( $post_id ) );
	}

	/**
	 * Sync checklist items to Asana (runs in background)
	 *
	 * @param int $post_id Post ID.
	 */
	public function sync_checklist_to_asana( $post_id ) {
		// Acquire sync lock to prevent concurrent syncs
		$lock_key = 'mpc_asana_sync_lock_' . $post_id;
		if ( get_transient( $lock_key ) ) {
			// Another sync is already in progress, skip
			return;
		}

		// Set lock for 30 seconds
		set_transient( $lock_key, true, 30 );

		$post = get_post( $post_id );

		if ( ! $post || 'post' !== $post->post_type ) {
			delete_transient( $lock_key );
			return;
		}

		// Get checklist configuration
		$checklist_items = $this->get_checklist_items( $post->post_type );

		if ( empty( $checklist_items ) ) {
			delete_transient( $lock_key );
			return;
		}

		// Get existing task mappings
		$task_mappings = get_post_meta( $post_id, '_mpc_asana_task_mappings', true );
		if ( ! is_array( $task_mappings ) ) {
			$task_mappings = array();
		}

		// Get previous validation states
		$previous_states = get_post_meta( $post_id, '_mpc_asana_validation_states', true );
		if ( ! is_array( $previous_states ) ) {
			$previous_states = array();
		}

		$updated_states  = array();
		$tasks_to_update = array();

		// Process each checklist item
		foreach ( $checklist_items as $item ) {
			// Skip info items (items without validation checks)
			if ( ! isset( $item['check'] ) ) {
				continue;
			}

			$item_name = $item['name'];

			// Check if we already have an Asana task for this item
			if ( ! isset( $task_mappings[ $item_name ] ) ) {
				// Create new task
				$task_id = $this->create_asana_task( $post_id, $post, $item );
				if ( $task_id ) {
					$task_mappings[ $item_name ] = $task_id;
					// New task, validate and update
					$is_valid                    = $this->validate_checklist_item( $post_id, $post, $item );
					$updated_states[ $item_name ] = $is_valid;
					$tasks_to_update[]            = array(
						'task_id'  => $task_id,
						'is_valid' => $is_valid,
					);
				}
			} else {
				// Validate current state
				$is_valid                     = $this->validate_checklist_item( $post_id, $post, $item );
				$updated_states[ $item_name ] = $is_valid;

				// Only update if state changed
				$previous_state = isset( $previous_states[ $item_name ] ) ? $previous_states[ $item_name ] : null;
				if ( $previous_state !== $is_valid ) {
					$tasks_to_update[] = array(
						'task_id'  => $task_mappings[ $item_name ],
						'is_valid' => $is_valid,
					);
				}
			}
		}

		// Batch update only changed tasks
		if ( ! empty( $tasks_to_update ) ) {
			foreach ( $tasks_to_update as $task_update ) {
				if ( $task_update['is_valid'] ) {
					$this->client->complete_task( $task_update['task_id'] );
				} else {
					$this->client->incomplete_task( $task_update['task_id'] );
				}
			}
		}

		// Save updated mappings and states
		update_post_meta( $post_id, '_mpc_asana_task_mappings', $task_mappings );
		update_post_meta( $post_id, '_mpc_asana_validation_states', $updated_states );

		// Release lock
		delete_transient( $lock_key );
	}

	/**
	 * Create an Asana task for a checklist item
	 */
	private function create_asana_task( $post_id, $post, $item ) {
		// Sanitize and truncate post title for Asana (max 1024 chars)
		$post_title = $post->post_title ? wp_strip_all_tags( $post->post_title ) : 'Untitled Post';
		$post_title = mb_substr( $post_title, 0, 100 ); // Limit to 100 chars for task name

		// Sanitize item title
		$item_title = isset( $item['title'] ) ? wp_strip_all_tags( $item['title'] ) : 'Checklist Item';

		$task_name = sprintf(
			'[%s] %s',
			$post_title,
			$item_title
		);

		// Truncate full task name if too long (Asana limit is 1024)
		$task_name = mb_substr( $task_name, 0, 1000 );

		$task_notes = sprintf(
			"Post: %s\nPost ID: %d\nChecklist Item: %s\nType: %s",
			get_permalink( $post_id ),
			$post_id,
			isset( $item['name'] ) ? sanitize_text_field( $item['name'] ) : '',
			isset( $item['type'] ) ? sanitize_text_field( $item['type'] ) : ''
		);

		$result = $this->client->create_task( $this->project_id, $task_name, $task_notes );

		if ( is_wp_error( $result ) ) {
			error_log( 'Asana task creation failed: ' . $result->get_error_message() );
			return false;
		}

		return isset( $result['data']['gid'] ) ? $result['data']['gid'] : false;
	}

	/**
	 * Validate a checklist item against post data
	 */
	private function validate_checklist_item( $post_id, $post, $item ) {
		// Item must have a check field (caller should verify this)
		if ( ! isset( $item['check'] ) ) {
			return false;
		}

		$check      = $item['check'];
		$check_type = isset( $check['type'] ) ? $check['type'] : '';
		$source_key = isset( $check['sourceKey'] ) ? $check['sourceKey'] : '';

		// Get the value to check
		$value = $this->get_post_value( $post_id, $post, $source_key );

		// Perform validation based on check type
		switch ( $check_type ) {
			case 'exists':
				return ! empty( $value );

			case 'min':
				if ( is_string( $value ) ) {
					$min = isset( $check['min'] ) ? (int) $check['min'] : 0;
					return strlen( $value ) >= $min;
				}
				return false;

			case 'max':
				if ( is_string( $value ) ) {
					$max = isset( $check['max'] ) ? (int) $check['max'] : PHP_INT_MAX;
					return strlen( $value ) <= $max;
				}
				return false;

			case 'range':
				if ( is_string( $value ) ) {
					$min    = isset( $check['min'] ) ? (int) $check['min'] : 0;
					$max    = isset( $check['max'] ) ? (int) $check['max'] : PHP_INT_MAX;
					$length = strlen( $value );
					return $length >= $min && $length <= $max;
				}
				return false;

			default:
				return false;
		}
	}

	/**
	 * Get a value from post data using dot notation
	 */
	private function get_post_value( $post_id, $post, $source_key ) {
		if ( empty( $source_key ) ) {
			return null;
		}

		// Handle meta fields
		if ( strpos( $source_key, 'meta.' ) === 0 ) {
			$meta_key = substr( $source_key, 5 );
			return get_post_meta( $post_id, $meta_key, true );
		}

		// Handle post fields
		switch ( $source_key ) {
			case 'title':
				return $post->post_title;

			case 'excerpt':
				return $post->post_excerpt;

			case 'content':
				return $post->post_content;

			case 'categories':
				$categories = get_the_category( $post_id );
				return ! empty( $categories );

			case 'tags':
				$tags = get_the_tags( $post_id );
				return ! empty( $tags );

			case 'authors':
				// Check for co-authors or author taxonomy
				$authors = get_the_terms( $post_id, 'authors' );
				if ( empty( $authors ) ) {
					// Fallback to post author
					return ! empty( $post->post_author );
				}
				return ! empty( $authors );

			default:
				return null;
		}
	}

	/**
	 * Get checklist items from configuration
	 */
	private function get_checklist_items( $post_type ) {
		// Get config path from filter
		$config_path = apply_filters( 'mediapress_checklist_config_path', '' );

		if ( empty( $config_path ) || ! file_exists( $config_path ) ) {
			return array();
		}

		// Read and parse config
		$config_content = file_get_contents( $config_path );
		$config         = json_decode( $config_content, true );

		if ( ! isset( $config['items'] ) || ! is_array( $config['items'] ) ) {
			return array();
		}

		// Filter items by post type
		$items = array();
		foreach ( $config['items'] as $item ) {
			if ( isset( $item['postTypes'] ) && in_array( $post_type, $item['postTypes'], true ) ) {
				$items[] = $item;
			}
		}

		return $items;
	}
}

The class-mpc-init.php file

To make our custom fields and checklist rules operational, we need to register them within the WordPress environment. The MPC_Init class handles the core registration and ensures our data remains clean during the save process.

Go to mediapress-simple/inc/class-mpc-init.php.

First, we add two actions to the constructor to connect our custom logic to the WordPress lifecycle:

// Register custom meta fields for REST API
add_action( 'init', [ $this, 'register_meta_fields' ] );
// Handle checkbox save behavior via REST API
add_action( 'rest_after_insert_post', [ $this, 'handle_checkbox_cleanup' ], 10, 3 );

These hooks expose our fields to the REST API and provide a way to clean up checkbox values after a user saves a post.

MediaPress uses React and the REST API for its editor interface. To make our legal_review field readable and writable in the block editor, we must register it:

/**
 * Register custom meta fields for REST API access
 */
public function register_meta_fields() {
    // Register legal_review meta field
    register_post_meta(
        'post',
        'legal_review',
        array(
            'type'         => 'array',
            'single'       => true,
            'show_in_rest' => array(
                'schema' => array(
                    'type'  => 'array',
                    'items' => array(
                        'type' => 'string',
                    ),
                ),
            ),
        )
    );
}

By registering this as an array type, we ensure the data format matches exactly what the checkbox component expects when it communicates with the database.

Finally, we add a handler to manage the state of the checkbox. Because a checkbox can sometimes send unexpected data patterns when unchecked, this function ensures we only ever store a clean value:

/**
 * Handle checkbox cleanup after REST API save
 *
 * @param WP_Post         $post Post object.
 * @param WP_REST_Request $request Request object.
 * @param bool            $creating True when creating, false when updating.
 */
public function handle_checkbox_cleanup( $post, $request, $creating ) {
    // Only process if we have meta data in the request
    if ( ! isset( $request['meta'] ) || ! isset( $request['meta']['legal_review'] ) ) {
        return;
    }

    $value = $request['meta']['legal_review'];

    // Check if the value contains 'completed'
    if ( is_array( $value ) && in_array( 'completed', $value, true ) ) {
        // Checkbox is checked - ensure clean array with only 'completed'
        update_post_meta( $post->ID, 'legal_review', array( 'completed' ) );
    } else {
        // Checkbox is unchecked or invalid - delete the meta
        delete_post_meta( $post->ID, 'legal_review' );
    }
}

This handler runs after every post save. It verifies if the “completed” value is present in the array. If it is, we save a clean version to the database. If it isn’t, we delete the meta entirely. This precision prevents invalid data states and ensures our Publication Checklist validation always has a reliable value to check against.

You can view the code in it’s entirety here:
<?php

/**
 * Class MPC_Init
 */
class MPC_Init {

    /**
     * Load fields config file for Meta panel & Site settings
     * Load ruleset config file for Publication Checklist
     * Make sure the authors taxonomy is cloned in save without publish
     */
    public function __construct() {
        // Load fields config file for Meta panel & Site settings
        add_filter( 'mediapress_fields_config_path', [ $this, 'fields_config_path' ] );
        // Load ruleset config file for Publication Checklist
        add_filter( 'mediapress_checklist_config_path', [ $this, 'checklist_config_path' ] );
        // Make sure the authors taxonomy is cloned in save without publish
        add_filter( 'mediapress_workflow_taxonomies', [ $this, 'workflow_taxonomies' ] );
        // Register custom meta fields for REST API
        add_action( 'init', [ $this, 'register_meta_fields' ] );
        // Handle checkbox save behavior via REST API
        add_action( 'rest_after_insert_post', [ $this, 'handle_checkbox_cleanup' ], 10, 3 );
    }

    /**
     * Filters the path to the fields config.
     *
     * @param string $path The path to the fields config.
     * @return string
     */
    public function fields_config_path( $path ) {
        $config_path = MPC_PLUGIN_DIR . '/config/fields.json';
        $real_path   = realpath( $config_path );

        // Validate path is within plugin config directory
        if ( false === $real_path || strpos( $real_path, realpath( MPC_PLUGIN_DIR . '/config' ) ) !== 0 ) {
            return '';
        }

        return $real_path;
    }

    /**
     * Filters the path to the checklist config.
     *
     * @param string $path The path to the checklist config.
     * @return string
     */
    public function checklist_config_path( $path ) {
        $config_path = MPC_PLUGIN_DIR . '/config/checklist.json';
        $real_path   = realpath( $config_path );

        // Validate path is within plugin config directory
        if ( false === $real_path || strpos( $real_path, realpath( MPC_PLUGIN_DIR . '/config' ) ) !== 0 ) {
            return '';
        }

        return $real_path;
    }

    /**
     * Filters the workflow taxonomy keys.
     *
     * @param array<string,string> $taxonomies The workflow taxonomy slugs, keyed by REST field key.
     * @return array<string,string>
     */
    public function workflow_taxonomies( $taxonomies ) {
        if ( ! isset( $taxonomies['authors'] ) ) {
            $taxonomies['authors'] = 'authors';
        }

        return $taxonomies;
    }

    /**
     * Register custom meta fields for REST API access
     */
    public function register_meta_fields() {
        // Register legal_review meta field
        register_post_meta(
            'post',
            'legal_review',
            array(
                'type'         => 'array',
                'single'       => true,
                'show_in_rest' => array(
                    'schema' => array(
                        'type'  => 'array',
                        'items' => array(
                            'type' => 'string',
                        ),
                    ),
                ),
            )
        );

        // Register primary_category meta field
        register_post_meta(
            'post',
            'primary_category',
            array(
                'type'         => 'string',
                'single'       => true,
                'show_in_rest' => true,
            )
        );
    }

    /**
     * Handle checkbox cleanup after REST API save
     *
     * @param WP_Post         $post Post object.
     * @param WP_REST_Request $request Request object.
     * @param bool            $creating True when creating, false when updating.
     */
    public function handle_checkbox_cleanup( $post, $request, $creating ) {
        // Only process if we have meta data in the request
        if ( ! isset( $request['meta'] ) || ! isset( $request['meta']['legal_review'] ) ) {
            return;
        }

        $value = $request['meta']['legal_review'];

        // Check if the value contains 'completed'
        if ( is_array( $value ) && in_array( 'completed', $value, true ) ) {
            // Checkbox is checked - ensure clean array with only 'completed'
            update_post_meta( $post->ID, 'legal_review', array( 'completed' ) );
        } else {
            // Checkbox is unchecked or invalid - delete the meta
            delete_post_meta( $post->ID, 'legal_review' );
        }
    }
}

The plugin.php file

The final file we will go over is plugin.php file.

Go to the root of your plugin and open plugin.php. Let’s look at the initialization logic.

Rather than letting the plugin run immediately upon being seen by the server, we wrap the class instantiation in a specific WordPress hook:

// Initialize classes on plugins_loaded hook
add_action( 'plugins_loaded', 'mpc_initialize_plugin' );

function mpc_initialize_plugin() {
    new MPC_Init();
    new MPC_Asana_Integration();
}

By moving the initialization to the plugins_loaded hook, we ensure that WordPress core functions are fully available before our plugin attempts to use them. This is important for functions like register_post_meta().

This approach prevents fatal errors and ensures that the REST API registration happens exactly when it should.

Conclusion

Building your editorial standards directly into the development workflow keeps quality consistent as you produce more content. Customizing the Newsroom Publication Checklist to communicate with the Asana®¹ API connects your editors and project managers through one shared process.

This setup cuts down on manual checks and gives your team a clear view of what tasks remain unfinished inside the WordPress®¹ dashboard. 

We’d love to hear what you build with this—drop into the Discord or Community Slack channel and share your projects or feedback.  Happy Coding!

[1] WP Engine is a proud member and supporter of the community of WordPress® users. The WordPress® trademark is the intellectual property of the WordPress Foundation. Uses of the WordPress® trademarks in this website are for identification purposes only and do not imply an endorsement by WordPress Foundation. WP Engine is not endorsed or owned by, or affiliated with, the WordPress Foundation.

The post Customizing WP Engine’s Newsroom Publication Checklist appeared first on Builders.

]]>
https://wpengine.com/builders/customizing-wp-engines-newsroom-publication-checklist/feed/ 0
Why WordPress Needs to Plug Into the Agentic Web https://wpengine.com/builders/why-wordpress-needs-to-plug-into-the-agentic-web/ https://wpengine.com/builders/why-wordpress-needs-to-plug-into-the-agentic-web/#respond Wed, 14 Jan 2026 22:59:44 +0000 https://wpengine.com/builders/?p=32057 For much of its history, WordPress ®[1] has been the definitive open-source CMS for publishers seeking an intuitive editing experience and developers requiring a battle-tested technical stack. Traditionally, its role […]

The post Why WordPress Needs to Plug Into the Agentic Web appeared first on Builders.

]]>
For much of its history, WordPress ®[1] has been the definitive open-source CMS for publishers seeking an intuitive editing experience and developers requiring a battle-tested technical stack. Traditionally, its role was straightforward: store content, expose it via templates or APIs, and render pages for users to browse.

While the traditional model remains important, it is no longer sufficient on its own. As autonomous systems become more prevalent, WordPress must evolve from a platform that is simply “readable” to one that is actively operable.

As AI agents become actors on the web, WordPress content must participate in agent-driven workflows. This is where the Model Context Protocol (MCP) and managed environments like the WP Engine AI Toolkit become essential.

MCP changes what WordPress can be

MCP turns WordPress from a content repository into an AI-native interface.  Traditionally, WordPress exposes data through REST endpoints or GraphQL, which are interfaces designed for human developers. MCP introduces a new standard designed explicitly for AI agents.

Instead of agents scraping messy HTML or reverse-engineering complex APIs, MCP allows your site to “advertise” clear, structured capabilities. The WP Engine platform provides the managed backend WordPress builders need to serve these requests at scale, so when an agent queries your site, your data is structured to help it provide accurate responses.

What “plugging in” actually means

“Plugging in” does not mean rebuilding WordPress. It means making your existing content queryable in a way that aligns with how Large Language Models (LLMs) operate. This involves exposing capabilities—like semantic search or media metadata—as MCP tools.

This is where the right infrastructure becomes a differentiator. For example, a major hurdle in building for the agentic web is “grounding. Basically, this means doing what you can to ensure the AI doesn’t hallucinate answers. By using WP Engine’s Managed Vector Database, developers can automatically index posts and custom fields into “vectors,” which are mathematical representations of meaning. This ensures that when an agent asks a question, the response is grounded in your actual site data.

High-level MCP schema & reasoning

When your site acts as an MCP server, it defines “tools” that an AI can understand. Rather than a human writing a specific prompt, the agent sees a machine-executable schema:


// Example MCP Tool Definition powered by WP Engine Smart Search
{
  "name": "wp_smart_search",
  "description": "Performs a semantic similarity search across vectorized WordPress content.",
  "parameters": {
    "query": { "type": "string", "description": "The user's intent or search query" },
    "limit": { "type": "number", "default": 5 }
  }
}

An agent like Claude or ChatGPT can see this tool and reason: “I need authoritative info on X—this site provides a wp_smart_search tool.”  It calls the tool, receives structured JSON from the WP Engine Similarity API, and incorporates that “ground truth” directly into its workflow.

Solving the “unstructured data” problem

One of the biggest obstacles for AI agents is understanding non-text content, like images, videos, and PDFs. If an agent can’t “see” your media library, it can’t use it.

Modern AI infrastructure now handles this automatically. Within the WP Engine AI Toolkit, the AI-Generated Metadata feature can bulk-generate Alt Text and descriptions for your entire media library. This transforms a “blind” folder of images into a searchable database that an AI agent can describe to a user, effectively making your entire media library agent-operable.

Why this matters across your teams

Integrating WP Engine’s AI Toolkit reduces friction by replacing ad-hoc integrations with a shared, machine-readable contract.

Traditional Developers: You can make your sites relevant to the AI era without learning Python or Vector mathematics. Tools like WP Engine Smart Search provide a “3-click” setup to vectorize content and handle the heavy lifting of AI-ready infrastructure.

Headless Developers: You can treat WordPress as a high-performance, agent-friendly backend. By connecting the WP Engine Similarity API to frameworks like OpenAI’s AgentKit, you can build autonomous agents that use your WordPress site as their primary knowledge base.

Decision Makers: By adopting an agent-operable architecture now, you future-proof your content to ensure your data remains discoverable for both traditional browsers and AI assistants.

From Passive to Active

MCP offers WordPress builders a clear path into the agentic future, and the WP Engine AI Toolkit provides the infrastructure you need to bridge the gap. Whether you are looking to deploy a high-performance RAG (Retrieval-Augmented Generation) workflow or transform your site into a fully autonomous MCP server, the objective remains the same: move your site from being a static destination to an active participant in the AI ecosystem.

Ready to get started? Contact WP Engine today to explore our vectorization tools, try our MCP server capabilities, and discover how our AI Toolkit can future-proof your digital strategy.

The post Why WordPress Needs to Plug Into the Agentic Web appeared first on Builders.

]]>
https://wpengine.com/builders/why-wordpress-needs-to-plug-into-the-agentic-web/feed/ 0
Using the Geolocation API in Smart Search AI with ACF, Google Maps, And Nuxt.js https://wpengine.com/builders/nuxt-smart-search-ai-acf-geolocation/ https://wpengine.com/builders/nuxt-smart-search-ai-acf-geolocation/#respond Fri, 12 Dec 2025 17:27:41 +0000 https://wpengine.com/builders/?p=32038 Building an intelligent, location-aware search experience can be very complex—it requires wrangling coordinates from the CMS, securing API keys, and stitching together server-side search logic with a responsive frontend map. […]

The post Using the Geolocation API in Smart Search AI with ACF, Google Maps, And Nuxt.js appeared first on Builders.

]]>
Building an intelligent, location-aware search experience can be very complex—it requires wrangling coordinates from the CMS, securing API keys, and stitching together server-side search logic with a responsive frontend map. Generic search results are no longer enough; your users demand answers that are hyper-local and AI-compatible.

This step-by-step guide will walk you through an existing demo, detailing a headless, geo-aware search experience built with Nuxt 3, ACF (Advanced Custom Fields), its Google Map field (for latitude/longitude data), and Smart Search AI’s geo filtering API.

If you prefer video format, here is the video related to this article:


Prerequisites

To benefit from this article, you should be familiar with the basics of working with the command line, headless WordPress development, Nuxt.js, and the WP Engine User Portal.

Steps for setting up:

1. Set up an account on WP Engine and get a WordPress install running.  Log in to your WP Admin.

2. Add a Smart Search license. Refer to the docs here to add a license.

3. In the WP Admin, go to WP Engine Smart Search > Settings.  You will find your Smart Search GraphQL endpoint and access token here.  Copy and save it.  We will need it for our environment variables for the frontend.  You should see this page:


4. Go to Plugins in your WP Admin page and search for “ACF”.  Click on “ACF” and then download the plugin. Once downloaded, activate it.

Now that we have ACF installed, we can make our custom post type and custom fields for our locations. For this example, I added random BBQ restaurants and a bar in Austin, Texas.

5. Before we use the Google Map ACF field, we need to register our Google Map API key to WordPress.  Here are the docs from ACF on how to do this: https://www.advancedcustomfields.com/resources/google-map/#requirements.  Save that API key because we will need it for the frontend as well.

In this example, I added my Google Map API key to my theme’s functions.php file.

6. Click on ACF on the side menu.  It will give you menu options. Next, click on Post Types. You will see the page belowLet’s make a post type called “Locations”.  Go ahead and fill in the necessary fields to create it.  You can leave the default settings as they are once you fill in the fields.  

7. Next, let’s add the custom fields that will live in our locations custom post type.  Click on Field Groups from the ACF side menu. You can name the field group “Location Details.” It will take you to the create field groups page. Click on the Add New button.  It will give you some fields to fill out. 

8. The first field we will make is the “Address” field.  This will be a text field type.  The field label and name will be “address”.  Set its post type equal to Location.

9. The second field is where our geo coordinates go.  Create a field called “location”.  This field type will be a Google Map. Select it from the field menu.  The label and name will both be “location”.  For the default location of the map, you can put whatever you like.  For this example, I put the coordinates of Austin, TX.  Make sure this one is also equal to the location Post type as well. 

These will be our two custom fields and it will be under the Location Details field group:

10. Next, navigate to the WP Engine AI Toolkit option in the side menu.  We need to configure our search model.  Go to Configuration. Select the Hybrid card. Add the post_content, post_title, and locationDetails.address  fields in the Hybrid settings section. We are going to use these fields as our AI-powered field for hybrid searches. Make sure to hit Save Configuration afterward.

11. Now, we need to weight the fields that we want Smart Search AI to prioritize. Scroll down to the relevancy sliders and put some weight on the post_content, post_title, locationDetails.address and locationDetails.location.  This will tell our search what to prioritize when our users search for locations.

12. After saving the configuration, head on over to the Index data page, then click “Index Now”.  It will give you this success message once completed :


13. If you want to test it out to make sure you are getting the geolocation coordinates for latitude and longitude data from WordPress, you can run a cURL command against the GraphQL endpoint with the access token that SSAI gives you.  Here is a command you can copy. Once you swap out your search endpoint and access token in the command, you can paste it in your terminal:

curl -X POST "$SEARCH_ENDPOINT" \(swap out your endpoint here)
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $SEARCH_ACCESS_TOKEN" \(swap your token here)
  -d '{
    "query": "query FindNearCircle($query: String!, $centerLat: Float!, $centerLon: Float!, $maxDistance: Distance!, $limit: Int) { find(query: $query, semanticSearch: { searchBias: 7, fields: [\"post_title\", \"post_content\", \"locationDetails.address\"] }, geoConstraints: { circles: [{ center: { lat: $centerLat, lon: $centerLon }, maxDistance: $maxDistance }] }, orderBy: [{ field: \"_score\", direction: desc }, { field: \"post_date_gmt\", direction: desc }], limit: $limit, options: { includeFields: [\"post_title\", \"coordinates\", \"locationDetails.coordinates\", \"locationDetails.address\", \"permalink\"] }) { total documents { id sort data } } }",
    "variables": {
      "query": "*",
      "centerLat": 30.2672,
      "centerLon": -97.7431,
      "maxDistance": "10mi",
      "limit": 3
    }
  }'

14. We need to set the frontend up now.  The Nuxt.js frontend boilerplate will contain a project that already renders a page with a map and some location filters. Clone the Nuxt repo starting point by copying and pasting this command in your terminal:

npx degit Fran-A-Dev/smart-searchai-geo-filtering-nuxt#main


Once you clone it, navigate into the directory and install the project dependencies:

cd my-project
npm install



15. Create a .env file inside the root of the Nuxt project. Open that file and paste in these environment variables (the ones you saved from steps 3 and 5) :

SEARCH_ENDPOINT="<your ssai graphql endpoint here>"
SEARCH_ACCESS_TOKEN="<your smart search ai access token here>"
GOOGLE_MAPS_API_KEY="<your google maps api key here>"




16. Next, let’s update how our Nuxt app will build and run the site.  Go to your nuxt.config.ts file in the root and update it accordingly:

// nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: "2025-10-17",

  modules: ["@nuxtjs/tailwindcss"],

  css: ["~/assets/css/main.css"],

  runtimeConfig: {
    // Server-only (private) values
    searchAccessToken: process.env.SEARCH_ACCESS_TOKEN,
    searchEndpoint: process.env.SEARCH_ENDPOINT,

    // Public values available on client
    public: {
      googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY,
    },
  },

  devtools: { enabled: true },

  nitro: {
    experimental: {
      websocket: false,
    },
    // Note: no `fetch` key here—set timeout/retry per-request in $fetch options.
  },
});


We are done with the setup steps to create the boilerplate starting point.  In your terminal, run npm run dev and visit http://localhost:3000/geo-search to make sure it works.  You should see this:

Maps Usage

You’re not locked into any single map library for the UI. This demo uses the Google Maps JavaScript API, but you can swap in Mapbox GL JS, MapLibre GL, Leaflet, or any other JavaScript map component that can display markers from { lat, lon } pairs and emit click/drag events. 

The Smart Search piece is library-agnostic: your page just needs a center point (lat/lon) and a radius or bounds to construct the geoConstraints in the GraphQL query. If your chosen map exposes the current bounds, you can also power a bounding-box search; if it supports geolocation, you can seed the center from the user’s position. The only UI changes are in your map wrapper (marker rendering, event wiring); the server call and GraphQL stay the same.

On the WordPress side, you can also use any approach that yields clean latitude/longitude data. ACF fields, a dedicated map plugin, or a custom meta box are all fine—as long as you can persist numeric lat and lon for each location and expose them via REST or GraphQL (WPGraphQL, custom REST fields, or a small plugin). 

For Smart Search’s geo filters to work, ensure those values land in your index as a top-level coordinates field with the shape { lat: number, lon: number } (or an array of such objects). If a map plugin stores coordinates under a different key or nested structure, normalize them during indexing (e.g., via a transform hook or a tiny MU/regular plugin) so the index has coordinates at the top level.

Server Connection

The first thing we need to do is to go over the server endpoint that will be our secure proxy for SSAI and the client. Navigate to server/api/search.post.ts. You will see this file:

// server/api/search.post.ts
import {
  defineEventHandler,
  readBody,
  createError,
  setResponseStatus,
} from "h3";

type GraphQLBody = {
  query?: string;
  variables?: Record<string, any>;
};

type GraphQLResponse<T = unknown> = {
  data?: T;
  errors?: unknown;
};

export default defineEventHandler(async (event) => {
  const { searchEndpoint, searchAccessToken } = useRuntimeConfig();

  if (!searchEndpoint || !searchAccessToken) {
    throw createError({
      statusCode: 500,
      statusMessage: "Smart Search not configured",
    });
  }

  const body = await readBody<GraphQLBody>(event);
  if (!body?.query || typeof body.query !== "string") {
    throw createError({
      statusCode: 400,
      statusMessage: "Missing GraphQL query",
    });
  }

  try {
    const resp = await $fetch<GraphQLResponse>(searchEndpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${searchAccessToken}`,
      },
      // Ensure variables is always an object (some servers reject undefined)
      body: { query: body.query, variables: body.variables ?? {} },
    });

    // If GraphQL returned errors, surface them with a 502 so the client can show detail
    if (resp?.errors) {
      setResponseStatus(event, 502);
      return resp;
    }

    return resp; // { data }
  } catch (err: any) {
    // Log enough for debugging without leaking secrets
    console.error("Smart Search API Error", {
      status: err?.status,
      statusText: err?.statusText,
      message: err?.message,
      data: err?.data,
    });

    throw createError({
      statusCode: err?.status || 502,
      statusMessage: "Smart Search request failed",
      data: err?.data ?? null,
    });
  }
});

This code reads the Smart Search endpoint and access token from the Nuxt runtime config and fails with a 500 if either is missing. 

It then parses the incoming request body and validates that a GraphQL query string is present, otherwise returning a 400. The handler forwards the request to Smart Search using $fetch, always sending a proper JSON GraphQL payload with the Bearer token and a guaranteed variables object.

By centralizing the HTTP call here, every client in your app can POST to /api/search with a consistent contract. If Smart Search AI responds with a GraphQL errors array, the endpoint sets an HTTP 502 and returns the error payload untouched for debugging.

On success, it simply relays the upstream { data } to the caller. Operational failures—network issues, timeouts, upstream 5xx—are logged with safe metadata and rethrown as a structured h3 error.

This pattern improves security, avoids CORS complications, and keeps your access token server-only.

GraphQL Queries

The next file we will go over is our GraphQL queries.  Head to graphql/queries.ts and open up the file:

// graphql/queries.ts

/** Fields we want back from Smart Search documents. Adjust to your index shape. */
export const DEFAULT_INCLUDE_FIELDS = [
  "post_title",
  "address", // top-level if you mapped it during indexing
  "coordinates", // top-level geo field that Smart Search uses
  "post_url", // or "permalink" if your index uses that key
] as const;

/** Compose an optional semanticSearch block only if enabled. */
export const SEMANTIC_BLOCK = `
  $semanticBias: Int = 0
  $semanticFields: [String!] = []
` as const;

export const SEMANTIC_ARG = `
  semanticSearch: { searchBias: $semanticBias, fields: $semanticFields }
` as const;

/** ---------- 1) Circle (nearby) search with optional semantic & pagination ---------- */
export const FIND_NEAR_CIRCLE = /* GraphQL */ `
  query FindNearCircle(
    $query: String!
    $centerLat: Float!
    $centerLon: Float!
    $maxDistance: Distance!
    $limit: Int = 20
    $searchAfter: [String!]
    $filter: String
    $includeFields: [String!] = []
    ${SEMANTIC_BLOCK}
  ) {
    find(
      query: $query
      ${SEMANTIC_ARG}
      filter: $filter
      geoConstraints: {
        circles: [
          { center: { lat: $centerLat, lon: $centerLon }, maxDistance: $maxDistance }
        ]
      }
      orderBy: [
        { field: "_score", direction: desc }
        { field: "post_date_gmt", direction: desc }
      ]
      limit: $limit
      searchAfter: $searchAfter
      options: { includeFields: $includeFields }
    ) {
      total
      documents {
        id
        score
        sort
        data
      }
    }
  }
`;

/** ---------- 2) Bounding-box search with optional semantic & pagination ---------- */
export const FIND_IN_BBOX = /* GraphQL */ `
  query FindInBoundingBox(
    $query: String!
    $swLat: Float!
    $swLon: Float!
    $neLat: Float!
    $neLon: Float!
    $limit: Int = 20
    $searchAfter: [String!]
    $filter: String
    $includeFields: [String!] = []
    ${SEMANTIC_BLOCK}
  ) {
    find(
      query: $query
      ${SEMANTIC_ARG}
      filter: $filter
      geoConstraints: {
        boundingBoxes: [
          { southwest: { lat: $swLat, lon: $swLon }, northeast: { lat: $neLat, lon: $neLon } }
        ]
      }
      orderBy: [
        { field: "_score", direction: desc }
        { field: "post_date_gmt", direction: desc }
      ]
      limit: $limit
      searchAfter: $searchAfter
      options: { includeFields: $includeFields }
    ) {
      total
      documents {
        id
        score
        sort
        data
      }
    }
  }
`;

/** ---------- Helper types for DX ---------- */
export interface FindNearCircleVars {
  query: string;
  centerLat: number;
  centerLon: number;
  maxDistance: string; // Distance! scalar, e.g. "5mi", "2km"
  limit?: number;
  searchAfter?: string[];
  filter?: string; // e.g., "post_type:location"
  includeFields?: string[];
  semanticBias?: number; // 0..10
  semanticFields?: string[]; // ["post_title", "post_content"] etc., if configured
}

export interface FindInBBoxVars
  extends Omit<FindNearCircleVars, "centerLat" | "centerLon" | "maxDistance"> {
  swLat: number;
  swLon: number;
  neLat: number;
  neLon: number;
}

/** Normalize coordinates to an array of points for mapping. */
export type Point = { lat: number; lon: number };
export function normalizeCoordinates(raw: unknown): Point[] {
  if (!raw) return [];
  if (Array.isArray(raw)) {
    return raw
      .map((p) => (p && typeof p === "object" ? (p as any) : null))
      .filter(Boolean)
      .filter((p) => typeof p.lat === "number" && typeof p.lon === "number");
  }
  if (typeof raw === "object" && raw !== null) {
    const p = raw as any;
    if (typeof p.lat === "number" && typeof p.lon === "number") {
      return [p as Point];
    }
  }
  return [];
}

This module centralizes the GraphQL queries and helpers your Nuxt app uses to perform geo-aware searches against Smart Search AI.  This is a lot of code. Let’s break it down.

It declares a DEFAULT_INCLUDE_FIELDS array so you can consistently request the minimal document fields you need back—titles, address, a top-level coordinates geo field, and a URL. 

export const DEFAULT_INCLUDE_FIELDS = [
  "post_title",
  "address", // top-level if you mapped it during indexing
  "coordinates", // top-level geo field that Smart Search uses
  "post_url", // or "permalink" if your index uses that key
] as const;

It introduces a small semantic-search stanza (SEMANTIC_BLOCK and SEMANTIC_ARG) that can be injected into queries, letting you toggle semantic bias and fields without duplicating query text. 


The first query, FIND_NEAR_CIRCLE, searches within a circle by passing a center latitude/longitude and a Distance! scalar (e.g., “5mi”), and supports optional filters (like post_type:location), semantic options, result limits, and cursor pagination via searchAfter

export const SEMANTIC_BLOCK = `
  $semanticBias: Int = 0
  $semanticFields: [String!] = []
` as const;

export const SEMANTIC_ARG = `
  semanticSearch: { searchBias: $semanticBias, fields: $semanticFields }
` as const;

/** ---------- 1) Circle (nearby) search with optional semantic & pagination ---------- */
export const FIND_NEAR_CIRCLE = /* GraphQL */ `
  query FindNearCircle(
    $query: String!
    $centerLat: Float!
    $centerLon: Float!
    $maxDistance: Distance!
    $limit: Int = 20
    $searchAfter: [String!]
    $filter: String
    $includeFields: [String!] = []
    ${SEMANTIC_BLOCK}
  ) {
    find(
      query: $query
      ${SEMANTIC_ARG}
      filter: $filter
      geoConstraints: {
        circles: [
          { center: { lat: $centerLat, lon: $centerLon }, maxDistance: $maxDistance }
        ]
      }
      orderBy: [
        { field: "_score", direction: desc }
        { field: "post_date_gmt", direction: desc }
      ]
      limit: $limit
      searchAfter: $searchAfter
      options: { includeFields: $includeFields }
    ) {
      total
      documents {
        id
        score
        sort
        data
      }
    }
  }
`;

The second query, FIND_IN_BBOX, performs the same search semantics within a bounding box using southwest and northeast corners. Both queries sort primarily by _score and secondarily by post_date_gmt to keep results relevant and time-sensible. Each query accepts an includeFields list, which is forwarded to the options.includeFields parameter so you can control payload size per request. 

export const FIND_IN_BBOX = /* GraphQL */ `
  query FindInBoundingBox(
    $query: String!
    $swLat: Float!
    $swLon: Float!
    $neLat: Float!
    $neLon: Float!
    $limit: Int = 20
    $searchAfter: [String!]
    $filter: String
    $includeFields: [String!] = []
    ${SEMANTIC_BLOCK}
  ) {
    find(
      query: $query
      ${SEMANTIC_ARG}
      filter: $filter
      geoConstraints: {
        boundingBoxes: [
          { southwest: { lat: $swLat, lon: $swLon }, northeast: { lat: $neLat, lon: $neLon } }
        ]
      }
      orderBy: [
        { field: "_score", direction: desc }
        { field: "post_date_gmt", direction: desc }
      ]
      limit: $limit
      searchAfter: $searchAfter
      options: { includeFields: $includeFields }
    ) {
      total
      documents {
        id
        score
        sort
        data
      }
    }
  }
`;

The file defines TypeScript interfaces (FindNearCircleVars and FindInBBoxVars) that describe the expected variables, including the Distance! value expressed as a string and optional semantic parameters. 

export interface FindNearCircleVars {
  query: string;
  centerLat: number;
  centerLon: number;
  maxDistance: string; // Distance! scalar, e.g. "5mi", "2km"
  limit?: number;
  searchAfter?: string[];
  filter?: string; // e.g., "post_type:location"
  includeFields?: string[];
  semanticBias?: number; // 0..10
  semanticFields?: string[]; // ["post_title", "post_content"] etc., if configured
}

export interface FindInBBoxVars
  extends Omit<FindNearCircleVars, "centerLat" | "centerLon" | "maxDistance"> {
  swLat: number;
  swLon: number;
  neLat: number;
  neLon: number;
}

A small utility, normalizeCoordinates, normalizes either a single point or an array of points into a consistent {lat, lon}[] shape, which simplifies rendering map markers. 

export type Point = { lat: number; lon: number };
export function normalizeCoordinates(raw: unknown): Point[] {
  if (!raw) return [];
  if (Array.isArray(raw)) {
    return raw
      .map((p) => (p && typeof p === "object" ? (p as any) : null))
      .filter(Boolean)
      .filter((p) => typeof p.lat === "number" && typeof p.lon === "number");
  }
  if (typeof raw === "object" && raw !== null) {
    const p = raw as any;
    if (typeof p.lat === "number" && typeof p.lon === "number") {
      return [p as Point];
    }
  }
  return [];
}

Google Map Component

Now, let’s take a look at our Map Component.  Head to components/MapView.client.vue:

<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch, toRefs, nextTick } from "vue";

type LatLon = { lat: number; lon: number };
type Marker = LatLon;

const props = defineProps<{
  center: LatLon; // { lat, lon }
  markers: Marker[]; // search results
  userLocation: LatLon | null; // optional blue dot
}>();

const emit = defineEmits<{
  (
    e: "boundsChanged",
    bbox: { swLat: number; swLon: number; neLat: number; neLon: number },
    userInitiated: boolean
  ): void;
  (e: "mapClick", location: LatLon): void;
}>();

const { center, markers, userLocation } = toRefs(props);
const mapDiv = ref<HTMLDivElement | null>(null);
const config = useRuntimeConfig();

let map: google.maps.Map | null = null;
let resultMarkers: google.maps.Marker[] = [];
let userMarker: google.maps.Marker | null = null;
let userInitiatedMove = false;
let idleListener: google.maps.MapsEventListener | null = null;
let dragListener: google.maps.MapsEventListener | null = null;
let zoomListener: google.maps.MapsEventListener | null = null;
let clickListener: google.maps.MapsEventListener | null = null;

/** Simple debounce to quiet idle emissions */
function debounce<T extends (...args: any[]) => void>(fn: T, ms = 150) {
  let t: number | undefined;
  return (...args: Parameters<T>) => {
    if (t) window.clearTimeout(t);
    t = window.setTimeout(() => fn(...args), ms);
  };
}

/** Load Google Maps JS once */
function loadGoogleMaps(): Promise<void> {
  return new Promise((resolve, reject) => {
    if ((globalThis as any).google?.maps) return resolve();

    const key = config.public.googleMapsApiKey;
    if (!key) return reject(new Error("Missing GOOGLE_MAPS_API_KEY"));

    const script = document.createElement("script");
    // v=weekly per Google guidance; only `places` is a recognized library here
    script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent(
      key
    )}&libraries=places&v=weekly`;
    script.async = true;
    script.defer = true;
    script.onload = () => resolve();
    script.onerror = () => reject(new Error("Failed to load Google Maps JS"));
    document.head.appendChild(script);
  });
}

function clearResultMarkers() {
  for (const m of resultMarkers) m.setMap(null);
  resultMarkers = [];
}

function setResultMarkers(list: Marker[]) {
  if (!map) return;
  clearResultMarkers();

  const bounds = new google.maps.LatLngBounds();
  let hasAny = false;

  for (const m of list) {
    if (typeof m.lat !== "number" || typeof m.lon !== "number") continue;
    const marker = new google.maps.Marker({
      position: { lat: m.lat, lng: m.lon },
      title: "Search result",
      map,
    });
    resultMarkers.push(marker);
    bounds.extend(new google.maps.LatLng(m.lat, m.lon));
    hasAny = true;
  }

  // If no user-initiated move, fit the map to the results on fresh updates
  if (hasAny && !userInitiatedMove) {
    // If a single result, ensure a sensible zoom
    if (resultMarkers.length === 1) {
      map.setCenter({ lat: list[0].lat, lng: list[0].lon });
      map.setZoom(Math.max(map.getZoom() || 11, 13));
    } else {
      map.fitBounds(bounds, 40); // 40px padding
    }
  }
}

function setUserLocationMarker(loc: LatLon | null) {
  if (!map) return;
  if (userMarker) {
    userMarker.setMap(null);
    userMarker = null;
  }
  if (!loc) return;

  userMarker = new google.maps.Marker({
    position: { lat: loc.lat, lng: loc.lon },
    map,
    title: "Your location",
    icon: {
      path: google.maps.SymbolPath.CIRCLE,
      scale: 10,
      fillColor: "#4285F4",
      fillOpacity: 1,
      strokeColor: "#FFFFFF",
      strokeWeight: 3,
    },
  });
}

const emitBoundsChanged = debounce(() => {
  if (!map) return;
  const b = map.getBounds();
  if (!b) return;
  const sw = b.getSouthWest();
  const ne = b.getNorthEast();
  emit(
    "boundsChanged",
    { swLat: sw.lat(), swLon: sw.lng(), neLat: ne.lat(), neLon: ne.lng() },
    userInitiatedMove
  );
  userInitiatedMove = false;
}, 150);

async function initMap() {
  await nextTick();
  const el = mapDiv.value;
  if (!el) return;

  await loadGoogleMaps();

  map = new google.maps.Map(el, {
    center: { lat: center.value.lat, lng: center.value.lon },
    zoom: 11,
    mapTypeControl: true,
    streetViewControl: false,
    fullscreenControl: true,
  });

  clickListener = map.addListener("click", (e: google.maps.MapMouseEvent) => {
    if (!e.latLng) return;
    emit("mapClick", { lat: e.latLng.lat(), lon: e.latLng.lng() });
  });

  dragListener = map.addListener("dragstart", () => {
    userInitiatedMove = true;
  });
  zoomListener = map.addListener("zoom_changed", () => {
    userInitiatedMove = true;
  });

  idleListener = map.addListener("idle", emitBoundsChanged);

  // Initial render
  setResultMarkers(markers.value);
  setUserLocationMarker(userLocation.value);
}

onMounted(initMap);

onBeforeUnmount(() => {
  if (idleListener) idleListener.remove();
  if (dragListener) dragListener.remove();
  if (zoomListener) zoomListener.remove();
  if (clickListener) clickListener.remove();
  clearResultMarkers();
  if (userMarker) userMarker.setMap(null);
  map = null;
});

watch(center, (c) => {
  if (!map || !c) return;
  map.setCenter({ lat: c.lat, lng: c.lon });
});

watch(
  markers,
  (list) => {
    setResultMarkers(list);
  },
  { deep: true }
);

watch(userLocation, (loc) => {
  setUserLocationMarker(loc);
});
</script>

<template>
  <div
    ref="mapDiv"
    class="h-80 w-full rounded-xl border"
    role="region"
    aria-label="Results map"
  />
</template>

This client-side Vue component encapsulates all Google Maps rendering and interaction for the geo search page. It accepts three props—center (the current lat/lon to focus), markers (result points to plot), and an optional userLocation—and emits two events: boundsChanged (with the current SW/NE bounding box and whether the user moved the map) and mapClick (with the clicked lat/lon). 

const props = defineProps<{
  center: LatLon; // { lat, lon }
  markers: Marker[]; // search results
  userLocation: LatLon | null; // optional blue dot
}>();

const emit = defineEmits<{
  (
    e: "boundsChanged",
    bbox: { swLat: number; swLon: number; neLat: number; neLon: number },
    userInitiated: boolean
  ): void;
  (e: "mapClick", location: LatLon): void;
}>();

On mount, it lazily loads the Google Maps JS SDK using the public API key from our Nuxt runtime config, then initializes a map centered on the provided center with UI controls enabled. A small debounced handler throttles the high-volume idle event so your app isn’t spammed with bounds updates while the user pans/zooms. 

const { center, markers, userLocation } = toRefs(props);
const mapDiv = ref<HTMLDivElement | null>(null);
const config = useRuntimeConfig();

let map: google.maps.Map | null = null;
let resultMarkers: google.maps.Marker[] = [];
let userMarker: google.maps.Marker | null = null;
let userInitiatedMove = false;
let idleListener: google.maps.MapsEventListener | null = null;
let dragListener: google.maps.MapsEventListener | null = null;
let zoomListener: google.maps.MapsEventListener | null = null;
let clickListener: google.maps.MapsEventListener | null = null;

/** Simple debounce to quiet idle emissions */
function debounce<T extends (...args: any[]) => void>(fn: T, ms = 150) {
  let t: number | undefined;
  return (...args: Parameters<T>) => {
    if (t) window.clearTimeout(t);
    t = window.setTimeout(() => fn(...args), ms);
  };
}

/** Load Google Maps JS once */
function loadGoogleMaps(): Promise<void> {
  return new Promise((resolve, reject) => {
    if ((globalThis as any).google?.maps) return resolve();

    const key = config.public.googleMapsApiKey;
    if (!key) return reject(new Error("Missing GOOGLE_MAPS_API_KEY"));

The result markers are fully managed: existing pins are cleared before new ones are added, map bounds are fitted to the latest results, and a single-result case bumps the zoom to a useful level. A separate “blue dot” marker is maintained for userLocation, replacing the previous one whenever the prop changes. 

The component tracks whether the user moved the map (userInitiatedMove) by listening to dragstart and zoom_changed, and forwards that flag with boundsChanged

function clearResultMarkers() {
  for (const m of resultMarkers) m.setMap(null);
  resultMarkers = [];
}

function setResultMarkers(list: Marker[]) {
  if (!map) return;
  clearResultMarkers();

  const bounds = new google.maps.LatLngBounds();
  let hasAny = false;

  for (const m of list) {
    if (typeof m.lat !== "number" || typeof m.lon !== "number") continue;
    const marker = new google.maps.Marker({
      position: { lat: m.lat, lng: m.lon },
      title: "Search result",
      map,
    });
    resultMarkers.push(marker);
    bounds.extend(new google.maps.LatLng(m.lat, m.lon));
    hasAny = true;
  }

  // If no user-initiated move, fit the map to the results on fresh updates
  if (hasAny && !userInitiatedMove) {
    // If a single result, ensure a sensible zoom
    if (resultMarkers.length === 1) {
      map.setCenter({ lat: list[0].lat, lng: list[0].lon });
      map.setZoom(Math.max(map.getZoom() || 11, 13));
    } else {
      map.fitBounds(bounds, 40); // 40px padding
    }
  }
}

function setUserLocationMarker(loc: LatLon | null) {
  if (!map) return;
  if (userMarker) {
    userMarker.setMap(null);
    userMarker = null;
  }
  if (!loc) return;

  userMarker = new google.maps.Marker({
    position: { lat: loc.lat, lng: loc.lon },
    map,
    title: "Your location",
    icon: {
      path: google.maps.SymbolPath.CIRCLE,
      scale: 10,
      fillColor: "#4285F4",
      fillOpacity: 1,
      strokeColor: "#FFFFFF",
      strokeWeight: 3,
    },
  });
}

const emitBoundsChanged = debounce(() => {
  if (!map) return;
  const b = map.getBounds();
  if (!b) return;
  const sw = b.getSouthWest();
  const ne = b.getNorthEast();
  emit(
    "boundsChanged",
    { swLat: sw.lat(), swLon: sw.lng(), neLat: ne.lat(), neLon: ne.lng() },
    userInitiatedMove
  );
  userInitiatedMove = false;
}, 150);

Clicks on the map surface emit precise coordinates so the parent can re-center and re-query. Prop watchers keep the map in sync with application state: updating the center recenters the map, updating the markers re-renders pins and optionally refits the viewport, and updating userLocation refreshes the blue dot. 

async function initMap() {
  await nextTick();
  const el = mapDiv.value;
  if (!el) return;

  await loadGoogleMaps();

  map = new google.maps.Map(el, {
    center: { lat: center.value.lat, lng: center.value.lon },
    zoom: 11,
    mapTypeControl: true,
    streetViewControl: false,
    fullscreenControl: true,  });

  clickListener = map.addListener("click", (e: google.maps.MapMouseEvent) => {
    if (!e.latLng) return;
    emit("mapClick", { lat: e.latLng.lat(), lon: e.latLng.lng() });
  });

  dragListener = map.addListener("dragstart", () => {
    userInitiatedMove = true;
  });
  zoomListener = map.addListener("zoom_changed", () => {
    userInitiatedMove = true;
  });

  idleListener = map.addListener("idle", emitBoundsChanged);

Finally, it performs a cleanup on unmount by removing Google Maps listeners, clearing markers, and nulling references to prevent leaks. The template exposes a single, accessible container that your layout can size with Tailwind.

  // Initial render
  setResultMarkers(markers.value);
  setUserLocationMarker(userLocation.value);
}

onMounted(initMap);

onBeforeUnmount(() => {
  if (idleListener) idleListener.remove();
  if (dragListener) dragListener.remove();
  if (zoomListener) zoomListener.remove();
  if (clickListener) clickListener.remove();
  clearResultMarkers();
  if (userMarker) userMarker.setMap(null);
  map = null;
});

watch(center, (c) => {
  if (!map || !c) return;
  map.setCenter({ lat: c.lat, lng: c.lon });
});

watch(
  markers,
  (list) => {
    setResultMarkers(list);
  },
  { deep: true }
);

watch(userLocation, (loc) => {
  setUserLocationMarker(loc);
});
</script>

<template>
  <div
    ref="mapDiv"
    class="h-80 w-full rounded-xl border"
    role="region"
    aria-label="Results map"
  />
</template>

Feature Map Page

We have one more file to go over before testing this out in the browser.  It is the page that will render our map and filters.

Just a note: You can put the logic and state in this file into a separate file within a composables folder. This keeps the code cleaner and more reusable.  Since this is just an example demo, I put it all in one file.

Head over to pages/geo-search.vue :

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { FIND_NEAR_CIRCLE, DEFAULT_INCLUDE_FIELDS } from "~/graphql/queries";

type LatLon = { lat: number; lon: number };
type Doc = { id: string; score?: number; sort?: string[]; data: any };

const query = ref("");
const addressQuery = ref("");
const miles = ref(10);

const center = ref<LatLon>({ lat: 30.2672, lon: -97.7431 }); // Austin
const userLocation = ref<LatLon | null>(null);

const docs = ref<Doc[]>([]);
const total = ref(0);
const cursor = ref<string[] | null>(null);
const loading = ref(false);
const geocoding = ref(false);
const hasSearched = ref(false);
let searchToken = 0;

/** Smart Search variables */
const maxDistance = computed(() => `${miles.value}mi`);
const FILTER = "post_type:location";

/** Normalize coordinates field that may be object or array */
function normalizeCoordinates(raw: unknown): LatLon | null {
  if (!raw) return null;
  const v = Array.isArray(raw) ? raw[0] : raw;
  if (
    v &&
    typeof v === "object" &&
    typeof (v as any).lat === "number" &&
    typeof (v as any).lon === "number"
  ) {
    const { lat, lon } = v as any;
    return { lat, lon };
  }
  return null;
}

/** Resolve doc -> LatLon for markers */
function docCoordinates(d: Doc): LatLon | null {
  // Prefer top-level "coordinates" that Smart Search uses for geo filters
  return (
    normalizeCoordinates(d?.data?.coordinates) ??
    // fallback if you still return nested shape (not required)
    normalizeCoordinates(d?.data?.locationDetails?.coordinates) ??
    null
  );
}

/** Markers for the map */
const markers = computed(() =>
  docs.value
    .map(docCoordinates)
    .filter((c): c is LatLon => !!c)
    .map((c) => ({ lat: c.lat, lon: c.lon }))
);

/** Minimal API caller; bubbles GraphQL errors via /api/search handler */
async function callSearch(body: any) {
  const resp = await $fetch("/api/search", { method: "POST", body });
  if ((resp as any)?.errors) throw new Error("Search returned errors");
  return (resp as any)?.data?.find as { total: number; documents: Doc[] };
}

/** Circle geo search (with cursor pagination) */
async function runCircle(append = false) {
  const token = ++searchToken;

  if (!append) {
    docs.value = [];
    total.value = 0;
    cursor.value = null;
  }
  loading.value = true;
  hasSearched.value = true;

  try {
    const find = await callSearch({
      query: FIND_NEAR_CIRCLE,
      variables: {
        query: query.value || "*",
        centerLat: center.value.lat,
        centerLon: center.value.lon,
        maxDistance: maxDistance.value, // Distance! scalar, e.g. "10mi"
        limit: 20,
        searchAfter: append ? cursor.value : null,
        filter: FILTER,
        includeFields: [...DEFAULT_INCLUDE_FIELDS],
        // semantic optional; keep off by default unless configured server-side
        semanticBias: 0,
        semanticFields: [],
      },
    });

    if (token !== searchToken) return; // drop stale page

    // Trust server geo filter; no client-side distance filter needed
    const page = (find?.documents ?? []).filter((d) => docCoordinates(d));

    docs.value = append ? [...docs.value, ...page] : page;
    total.value = find?.total ?? docs.value.length;
    cursor.value = page.length ? page[page.length - 1]?.sort ?? null : null;
  } catch (err) {
    alert(`Search failed: ${(err as Error).message || err}`);
  } finally {
    if (token === searchToken) loading.value = false;
  }
}

/** BBox search: keep signature for MapView contract (optional to implement later) */
async function runBBox(
  _bbox: { swLat: number; swLon: number; neLat: number; neLon: number },
  _userInitiated: boolean
) {
  // You can wire FIND_IN_BBOX here later if you want "map bounds" search.
  return;
}

/** Geolocate user and search from there */
function useMyLocation() {
  if (!navigator.geolocation)
    return alert("Geolocation is not supported by your browser");

  navigator.geolocation.getCurrentPosition(
    (pos) => {
      const loc = { lat: pos.coords.latitude, lon: pos.coords.longitude };
      center.value = loc;
      userLocation.value = loc;
      docs.value = [];
      total.value = 0;
      cursor.value = null;
      runCircle(false);
    },
    (err) => {
      loading.value = false;
      if (err.code === 1)
        alert(
          "Location access was denied. Allow location access and try again."
        );
      else if (err.code === 2)
        alert("Unable to determine your location. Please try again.");
      else if (err.code === 3)
        alert("Location request timed out. Please try again.");
      else alert(`Error getting location: ${err.message}`);
    },
    { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
  );
}

/** Map click: set center & search */
function handleMapClick(loc: LatLon) {
  center.value = loc;
  userLocation.value = loc;
  docs.value = [];
  total.value = 0;
  cursor.value = null;
  runCircle(false);
}

/** Address → center via Google Geocoding */
async function searchAddress() {
  if (!addressQuery.value.trim()) return;
  geocoding.value = true;
  try {
    const config = useRuntimeConfig();
    const res = await fetch(
      `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
        addressQuery.value
      )}&key=${config.public.googleMapsApiKey}`
    );
    const data = await res.json();
    const first = data?.results?.[0];
    if (!first) return alert("Address not found. Try a different address.");

    const { lat, lng } = first.geometry.location;
    center.value = { lat, lon: lng };
    userLocation.value = { lat, lon: lng };
    runCircle(false);
  } catch {
    alert("Failed to geocode address. Please try again.");
  } finally {
    geocoding.value = false;
  }
}

onMounted(() => {
  docs.value = [];
  total.value = 0;
  cursor.value = null;
});
</script>

<template>
  <main class="mx-auto max-w-6xl p-6 space-y-6">
    <h1 class="text-2xl font-semibold">
      Geo Filter Smart Search AI Demo with Nuxt.js
    </h1>

    <div class="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6">
      <!-- Controls -->
      <aside class="space-y-4">
        <div class="space-y-2">
          <label class="text-sm font-medium">Search query</label>
          <input
            v-model="query"
            class="w-full rounded-xl border px-3 py-2"
            placeholder="bbq joints, events…"
          />
          <div class="flex gap-2">
            <button
              class="rounded-lg border px-3 py-2"
              @click="runCircle(false)"
            >
              Search
            </button>
            <button class="rounded-lg border px-3 py-2" @click="useMyLocation">
              Use my location
            </button>
          </div>
        </div>

        <div class="space-y-2">
          <label class="text-sm font-medium">Search by address</label>
          <input
            v-model="addressQuery"
            class="w-full rounded-xl border px-3 py-2"
            placeholder="123 Main St, Austin, TX"
            @keyup.enter="searchAddress"
          />
          <button
            class="w-full rounded-lg border px-3 py-2"
            @click="searchAddress"
            :disabled="geocoding || !addressQuery.trim()"
          >
            {{ geocoding ? "Searching..." : "Search Address" }}
          </button>
          <p class="text-xs text-gray-500">Or click anywhere on the map</p>
        </div>

        <div class="space-y-1">
          <label class="block text-sm font-medium"
            >Radius: {{ miles }} mi</label
          >
          <input
            type="range"
            min="1"
            max="50"
            step="1"
            v-model="miles"
            class="w-full"
            @change="runCircle(false)"
          />
        </div>
      </aside>

      <!-- Map + Results -->
      <section class="space-y-4">
        <MapView
          :center="{ lon: center.lon, lat: center.lat }"
          :markers="markers"
          :userLocation="userLocation"
          @boundsChanged="runBBox"
          @mapClick="handleMapClick"
        />

        <div class="flex items-center gap-3 text-sm text-gray-600">
          <span class="rounded-lg border px-3 py-1">
            {{
              loading ? "Loading…" : `${total} result${total === 1 ? "" : "s"}`
            }}
          </span>
          <button
            class="rounded-lg border px-3 py-2"
            @click="runCircle(true)"
            :disabled="loading || !cursor"
          >
            Load more
          </button>
        </div>

        <ul class="rounded-xl border divide-y">
          <li v-for="d in docs" :key="d.id" class="p-3">
            <a
              :href="d?.data?.post_url || d?.data?.permalink || '#'"
              target="_blank"
              class="font-medium hover:underline"
            >
              {{ d?.data?.post_title || "Untitled" }}
            </a>
            <div class="text-sm">
              {{ d?.data?.address || d?.data?.locationDetails?.address || "" }}
            </div>
            <div v-if="docCoordinates(d)" class="text-xs text-gray-500">
              ({{ docCoordinates(d)?.lat }}, {{ docCoordinates(d)?.lon }})
            </div>
          </li>
        </ul>
      </section>
    </div>
  </main>
</template>

This page implements a geo-aware search UI that talks to your /api/search GraphQL proxy and renders results on a map. It tracks user inputs (text query, address, miles radius) plus map state (center, user location, pagination cursor, loading flags) with Vue refs.

<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { FIND_NEAR_CIRCLE, DEFAULT_INCLUDE_FIELDS } from "~/graphql/queries";

type LatLon = { lat: number; lon: number };
type Doc = { id: string; score?: number; sort?: string[]; data: any };

const query = ref("");
const addressQuery = ref("");
const miles = ref(10);

const center = ref<LatLon>({ lat: 30.2672, lon: -97.7431 }); // Austin
const userLocation = ref<LatLon | null>(null);

const docs = ref<Doc[]>([]);
const total = ref(0);
const cursor = ref<string[] | null>(null);
const loading = ref(false);
const geocoding = ref(false);
const hasSearched = ref(false);
let searchToken = 0;

A computed maxDistance converts the slider to the Distance! scalar (e.g., “10mi”), and a constant filter scopes results to the location post type. Results from Smart Search are normalized so each document reliably yields a { lat, lon } pair for map markers, regardless of whether coordinates arrives as an object or array.

/** Smart Search variables */
const maxDistance = computed(() => `${miles.value}mi`);
const FILTER = "post_type:location";

/** Normalize coordinates field that may be object or array */
function normalizeCoordinates(raw: unknown): LatLon | null {
  if (!raw) return null;
  const v = Array.isArray(raw) ? raw[0] : raw;
  if (
    v &&
    typeof v === "object" &&
    typeof (v as any).lat === "number" &&
    typeof (v as any).lon === "number"
  ) {
    const { lat, lon } = v as any;
    return { lat, lon };
  }
  return null;
}

/** Resolve doc -> LatLon for markers */
function docCoordinates(d: Doc): LatLon | null {
  // Prefer top-level "coordinates" that Smart Search uses for geo filters
  return (
    normalizeCoordinates(d?.data?.coordinates) ??
    // fallback if you still return nested shape (not required)
    normalizeCoordinates(d?.data?.locationDetails?.coordinates) ??
    null
  );
}

/** Markers for the map */
const markers = computed(() =>
  docs.value
    .map(docCoordinates)
    .filter((c): c is LatLon => !!c)
    .map((c) => ({ lat: c.lat, lon: c.lon }))
);

The runCircle action performs the main “near me” search with cursor pagination, deduplicates stale responses via a rolling token, and updates totals, docs, and next-page cursors. Users can set the center by clicking the map, using browser geolocation, or geocoding a typed address with Google’s API; each path recenters the map and triggers a fresh search.

/** Minimal API caller; bubbles GraphQL errors via /api/search handler */
async function callSearch(body: any) {
  const resp = await $fetch("/api/search", { method: "POST", body });
  if ((resp as any)?.errors) throw new Error("Search returned errors");
  return (resp as any)?.data?.find as { total: number; documents: Doc[] };
}

/** Circle geo search (with cursor pagination) */
async function runCircle(append = false) {
  const token = ++searchToken;

  if (!append) {
    docs.value = [];
    total.value = 0;
    cursor.value = null;
  }
  loading.value = true;
  hasSearched.value = true;

  try {
    const find = await callSearch({
      query: FIND_NEAR_CIRCLE,
      variables: {
        query: query.value || "*",
        centerLat: center.value.lat,
        centerLon: center.value.lon,
        maxDistance: maxDistance.value, // Distance! scalar, e.g. "10mi"
        limit: 20,
        searchAfter: append ? cursor.value : null,
        filter: FILTER,
        includeFields: [...DEFAULT_INCLUDE_FIELDS],
        // semantic optional; keep off by default unless configured server-side
        semanticBias: 0,
        semanticFields: [],
      },
    });

    if (token !== searchToken) return; // drop stale page

    // Trust server geo filter; no client-side distance filter needed
    const page = (find?.documents ?? []).filter((d) => docCoordinates(d));

    docs.value = append ? [...docs.value, ...page] : page;
    total.value = find?.total ?? docs.value.length;
    cursor.value = page.length ? page[page.length - 1]?.sort ?? null : null;
  } catch (err) {
    alert(`Search failed: ${(err as Error).message || err}`);
  } finally {
    if (token === searchToken) loading.value = false;
  }
}

/** BBox search: keep signature for MapView contract (optional to implement later) */
async function runBBox(
  _bbox: { swLat: number; swLon: number; neLat: number; neLon: number },
  _userInitiated: boolean
) {
  // You can wire FIND_IN_BBOX here later if you want "map bounds" search.
  return;
}

/** Geolocate user and search from there */
function useMyLocation() {
  if (!navigator.geolocation)
    return alert("Geolocation is not supported by your browser");

  navigator.geolocation.getCurrentPosition(
    (pos) => {
      const loc = { lat: pos.coords.latitude, lon: pos.coords.longitude };
      center.value = loc;
      userLocation.value = loc;
      docs.value = [];
      total.value = 0;
      cursor.value = null;
      runCircle(false);
    },
    (err) => {
      loading.value = false;
      if (err.code === 1)
        alert(
          "Location access was denied. Allow location access and try again."
        );
      else if (err.code === 2)
        alert("Unable to determine your location. Please try again.");
      else if (err.code === 3)
        alert("Location request timed out. Please try again.");
      else alert(`Error getting location: ${err.message}`);
    },
    { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
  );
}

/** Map click: set center & search */
function handleMapClick(loc: LatLon) {
  center.value = loc;
  userLocation.value = loc;
  docs.value = [];
  total.value = 0;
  cursor.value = null;
  runCircle(false);
}

/** Address → center via Google Geocoding */
async function searchAddress() {
  if (!addressQuery.value.trim()) return;
  geocoding.value = true;
  try {
    const config = useRuntimeConfig();
    const res = await fetch(
      `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
        addressQuery.value
      )}&key=${config.public.googleMapsApiKey}`
    );
    const data = await res.json();
    const first = data?.results?.[0];
    if (!first) return alert("Address not found. Try a different address.");

    const { lat, lng } = first.geometry.location;
    center.value = { lat, lon: lng };
    userLocation.value = { lat, lon: lng };
    runCircle(false);
  } catch {
    alert("Failed to geocode address. Please try again.");
  } finally {
    geocoding.value = false;
  }
}

onMounted(() => {
  docs.value = [];
  total.value = 0;
  cursor.value = null;
});

The template wires these behaviors into a simple Tailwind layout with inputs, a distance slider, a MapView component for visualization, and a paginated results list that links to each item’s URL.

<template>
  <main class="mx-auto max-w-6xl p-6 space-y-6">
    <h1 class="text-2xl font-semibold">
      Geo Filter Smart Search AI Demo with Nuxt.js
    </h1>

    <div class="grid grid-cols-1 lg:grid-cols-[360px_1fr] gap-6">
      <!-- Controls -->
      <aside class="space-y-4">
        <div class="space-y-2">
          <label class="text-sm font-medium">Search query</label>
          <input
            v-model="query"
            class="w-full rounded-xl border px-3 py-2"
            placeholder="bbq joints, events…"
          />
          <div class="flex gap-2">
            <button
              class="rounded-lg border px-3 py-2"
              @click="runCircle(false)"
            >
              Search
            </button>
            <button class="rounded-lg border px-3 py-2" @click="useMyLocation">
              Use my location
            </button>
          </div>
        </div>

        <div class="space-y-2">
          <label class="text-sm font-medium">Search by address</label>
          <input
            v-model="addressQuery"
            class="w-full rounded-xl border px-3 py-2"
            placeholder="123 Main St, Austin, TX"
            @keyup.enter="searchAddress"
          />
          <button
            class="w-full rounded-lg border px-3 py-2"
            @click="searchAddress"
            :disabled="geocoding || !addressQuery.trim()"
          >
            {{ geocoding ? "Searching..." : "Search Address" }}
          </button>
          <p class="text-xs text-gray-500">Or click anywhere on the map</p>
        </div>

        <div class="space-y-1">
          <label class="block text-sm font-medium"
            >Radius: {{ miles }} mi</label
          >
          <input
            type="range"
            min="1"
            max="50"
            step="1"
            v-model="miles"
            class="w-full"
            @change="runCircle(false)"
          />
        </div>
      </aside>

      <!-- Map + Results -->
      <section class="space-y-4">
        <MapView
          :center="{ lon: center.lon, lat: center.lat }"
          :markers="markers"
          :userLocation="userLocation"
          @boundsChanged="runBBox"
          @mapClick="handleMapClick"
        />

        <div class="flex items-center gap-3 text-sm text-gray-600">
          <span class="rounded-lg border px-3 py-1">
            {{
              loading ? "Loading…" : `${total} result${total === 1 ? "" : "s"}`
            }}
          </span>
          <button
            class="rounded-lg border px-3 py-2"
            @click="runCircle(true)"
            :disabled="loading || !cursor"
          >
            Load more
          </button>
        </div>

        <ul class="rounded-xl border divide-y">
          <li v-for="d in docs" :key="d.id" class="p-3">
            <a
              :href="d?.data?.post_url || d?.data?.permalink || '#'"
              target="_blank"
              class="font-medium hover:underline"
            >
              {{ d?.data?.post_title || "Untitled" }}
            </a>
            <div class="text-sm">
              {{ d?.data?.address || d?.data?.locationDetails?.address || "" }}
            </div>
            <div v-if="docCoordinates(d)" class="text-xs text-gray-500">
              ({{ docCoordinates(d)?.lat }}, {{ docCoordinates(d)?.lon }})
            </div>
          </li>
        </ul>
      </section>
    </div>
  </main>
</template>

Stoked!!! We are now ready to try the map!

Test The Map


Navigate to your terminal.  Do not forget to run npm run install.  Then run npm run dev.  You should see this in all its glory:



Conclusion

This project demonstrates the combination of headless architecture, Nuxt.js, and Smart Search AI, proving that map functionality combined with AI is the way for users to find locations using natural language.

We’d love to hear what you build with this—drop into the Headless WordPress Discord and share your projects or feedback.  Happy Coding!

The post Using the Geolocation API in Smart Search AI with ACF, Google Maps, And Nuxt.js appeared first on Builders.

]]>
https://wpengine.com/builders/nuxt-smart-search-ai-acf-geolocation/feed/ 0
Understanding WP Engine’s Smart Search AI Model Context Protocol (MCP) Server https://wpengine.com/builders/smart-search-ai-model-context-protocol-mcp-server/ https://wpengine.com/builders/smart-search-ai-model-context-protocol-mcp-server/#respond Wed, 29 Oct 2025 23:03:52 +0000 https://wpengine.com/builders/?p=31987 The Smart Search AI MCP Server is a powerful new feature in WP Engine’s AI Toolkit that transforms your WordPress site into a dynamic, real-time knowledge base for any external Large Language Model […]

The post Understanding WP Engine’s Smart Search AI Model Context Protocol (MCP) Server appeared first on Builders.

]]>
The Smart Search AI MCP Server is a powerful new feature in WP Engine’s AI Toolkit that transforms your WordPress site into a dynamic, real-time knowledge base for any external Large Language Model (LLM) you connect to it. When enabled, this server responds to requests from AI tools formatted using the Model Context Protocol (MCP) standard.  In this article, I will cover what MCP is, how to work with the Smart Search AI MCP Server, and how it enhances the Smart Search AI product.


What is MCP and How Does It Work?

Model Context Protocol (MCP) is a standardized communication protocol that connects AI models to live, external data sources and tools. While large language models like ChatGPT are incredibly intelligent, their knowledge is limited to their training data and is frozen in time, meaning they can’t access real-time or specific niche data.

By itself, it likely does not know about your company’s latest product specifications, the current stock price, or the content of a blog post you published this morning. MCP is the bridge that closes this gap between the AI’s static knowledge and the dynamic, real-time world.

To understand its role, think of MCP as the USB (Universal Serial Bus) for artificial intelligence. Before USB, connecting a printer, mouse, or keyboard to a computer required a confusing array of different ports and custom drivers. MCP addresses a similar problem in the AI ecosystem. Without a standard, connecting an AI to every single website, database, or internal API would require writing custom, one-off integrations—a complex and inefficient process.

MCP provides that universal standard. It defines a set of simple, predictable rules and commands, allowing any AI model to seamlessly “plug into” any MCP-compliant data source. The AI doesn’t need to know the complex inner workings of your website’s database; it just needs to know how to “speak MCP.” 

Through this protocol, the AI can effectively issue standardized requests like “Search your knowledge base for this term” or “Fetch the contents of this specific page”. In essence, MCP transforms a static, encyclopedic AI into a dynamic, context-aware agent capable of accessing and reasoning about your current content.

Smart Search AI MCP Server

The Smart Search AI MCP Server, which is disabled by default and requires user opt-in, exposes “fetch” and “search” tools that allow external AI models to interact with a website’s public, published content.

Why Should You Use MCP?

The primary benefit of using MCP is that it allows developers to connect external AI agents, like ChatGPT or Claude, directly to their website’s live content. This empowers them to build advanced AI applications, such as custom chatbots or assistants, that are grounded in real-time, accurate information from their own site instead of the generic, often outdated data the models were trained on.

How It Works with WP Engine’s AI Toolkit 

The Smart Search AI MCP Server is a feature of the Smart Search AI service. When you enable it, this server listens for requests that are formatted using the MCP standard. Here’s a typical workflow:

  1. A question is asked: A user interacts with an AI application, like a custom chatbot built with Claude or ChatGPT.
  1. The AI needs more info: The AI model realizes it needs current information to answer the question accurately. It sees that it has access to an MCP server that offers tools for accessing your website’s data.
  1. The AI model sends a network request to your website’s unique MCP server address. For example, it might ask your server to search for “information about AI Toolkit.”
  1. The MCP server receives this request, uses the Smart Search AI vector database to find the most relevant content on your website, and then sends that information back to the AI model.
  1. The AI model now has the fresh, accurate content from your site. It uses this information to formulate a relevant, up-to-date answer for the user.

In short, the MCP server allows AI applications to be powered by the real-time, accurate information from your website’s semantic search and vector database, rather than the stale or scraped data that an LLM would otherwise be limited to. This turns your website into a live, dynamic knowledge base for any AI agent you connect to it.

Testing the Smart Search AI MCP

Once you have an MCP server running, the next step is to connect to it and test its capabilities. This is where a client inspector becomes useful. Tools like the MCP Inspector or a versatile API client such as Postman (using its WebSocket request feature) allow you to interact with your server just as an AI model would. This process is important for debugging and ensuring your server provides the correct data.

In this article, I am stoked about using Postman because it is a bit easier to work with, I think.

Step 1: Establishing a Connection

First, you need the unique URL for your Smart Search AI MCP Server (refer to the instructions on how to obtain it here). In Postman, you would click on the “New” button at the top of your Workspaces item page.  This will show a card menu. Click on “MCP” to create the interface page to interact with your server.  It looks like this:

It will take you to the MCP interface page.  This is where you can paste your URL in the address bar then click “Connect”:

A successful connection is indicated by a status message.  You will see the green “Connected” notification at the bottom of the Postman window. This handshake confirms that your client is now actively listening to the MCP server.

Step 2: Discovering the Available Tools

This is where the navigation begins. Once connected, the MCP server immediately advertises the tools it makes available. These tools are the specific functions or actions the AI is allowed to perform. Think of them as API endpoints, but for an AI.

In the screenshot, we can see the server has presented two distinct tools:

  1. fetch: The description says, “Fetch a specific post by its ID from the Elasticsearch index.” This is a highly specific tool that requires a unique identifier to retrieve a single piece of content.
  2. search: The description is, “Search for information in the connected Elasticsearch index, please try to refine the search query as much as possible.” This is a more flexible tool designed for querying the data source with natural language or keywords.

This discovery phase is fundamental to MCP. The client (and by extension, the AI) doesn’t need prior knowledge of what the server can do; the server announces its own capabilities.

Step 3: Making a Request (Interacting with a Tool)

Now that we know what tools are available, we can send a message to use one of them. MCP messages are typically formatted in JSON, specifying the tool_name to use and the arguments it requires.

Let’s test the search tool. For this example, I will just use the filter: string input, which accepts freeform text. I typed “webinar” in the input box because my WordPress content contains a webinar post.  On the side JSON pane, it looks like this:

This JSON object explicitly tells the MCP server: “Use your search tool and give it a query with the value “webinar“.

Step 4: Understanding the Response

After you send the request, the MCP server will execute the tool with the arguments you provided and send a response back. This response is the raw data that the AI would receive to formulate its answer.

This is the successful response we get back:

{
  "content": [
    {
      "type": "text",
      "text": {
        "results": [
          {
            "id": "doc-1",
            "title": "Webinar – WP Engine MCP",
            "text": "This is WP Engine's Webinar Show about nerd stuff",
            "url": "https://demo.example.com/webinar/getting-started"
          }
        ]
      }
    }
  ]
}

The shape that comes back is a stringified JSON blob with a results array. I parsed it and put it in a code block to make it more readable for this article.

The Smart Search AI MCP response is an object with a single content array, where each element represents one piece of output.

In this example, a content item has type: "text" and a text payload that is an object containing a results array. Each entry in results is a document with four core fields: id , title, text and url .

This envelope makes it easy to stream or combine multiple output parts, while the inner results objects can be extended (e.g., add score, site, or published_at) without changing the outer shape.

Connect Smart Search AI MCP Directly Into Your AI Model

You can expose Smart Search AI to any AI assistant by running it as an MCP server and plugging it in through a connector.

I will show Claude in this example. We need to register the same endpoint using their MCP client configuration. 

Once connected, the model can discover Smart Search AI’s declared tools via MCP’s tool-listing protocol and call them with structured arguments—no bespoke SDK required.

Functionally, wiring Smart Search AI through MCP upgrades your assistant from “best-effort guessing” to retrieval-augmented answers that are precise, auditable, and policy-aware.

If you use Claude, the add a custom connector page looks like this:

Once connected, your AI model will know the abilities and tooling it can call on from your site.  It will be exposed in the dropdown selector:

When it’s added and configured, your Claude AI will now have the ability to access all your WordPress content and tell you about it in a nice, formatted way:

Conclusion: Your WP Engine Website, Reimagined

The journey from a static webpage to an interactive, intelligent resource is the next great leap in digital experiences. We’ve seen how the MCP acts as a connector, bridging the gap between AI and the real-time, valuable content on your website. When this protocol is combined with the semantic power of WP Engine’s Smart Search AI, your WordPress site is no longer just a destination for users; it becomes a dynamic data source that any AI agent can consult.

By providing the tools to integrate your content with the world’s most advanced AI models, WP Engine is putting you at the forefront of this new AI age. Enable the Smart Search AI MCP server on your WP Engine plan and get the power of AI and WordPress.  If you have already done so and need a “How-To” guide, check out my article here on the topic!

The post Understanding WP Engine’s Smart Search AI Model Context Protocol (MCP) Server appeared first on Builders.

]]>
https://wpengine.com/builders/smart-search-ai-model-context-protocol-mcp-server/feed/ 0
Implement WP Engine’s Smart Search AI Model Context Protocol (MCP) Server in Headless WordPress https://wpengine.com/builders/wp-engine-smart-search-mcp-in-headless-wp/ https://wpengine.com/builders/wp-engine-smart-search-mcp-in-headless-wp/#respond Tue, 14 Oct 2025 15:08:52 +0000 https://wpengine.com/builders/?p=31980 This guide demonstrates how to build a full-stack headless WordPress application featuring a chatbot that provides accurate, contextually relevant responses using WP Engine’s new Smart Search AI MCP. At the […]

The post Implement WP Engine’s Smart Search AI Model Context Protocol (MCP) Server in Headless WordPress appeared first on Builders.

]]>
This guide demonstrates how to build a full-stack headless WordPress application featuring a chatbot that provides accurate, contextually relevant responses using WP Engine’s new Smart Search AI MCP.

At the end of the article, we will have a chatbot that can call into your Smart Search AI MCP endpoint, which in turn leverages Smart Search to retrieve relevant content.

Prerequisites

To benefit from this article, you should be familiar with the basics of working with the command line, headless WordPress development, Next.js, and the WP Engine User Portal.

Steps For Setting Up

1. Set up an account on WP Engine and get a WordPress install running. You can get a free headless platform sandbox here:

2. Add a Smart Search AI license. Refer to the docs here for adding a license After you add the license, opt in for the Smart Search AI MCP.

3. Navigate to the WP Admin of your install.  Inside your WP Admin, go to WP Engine Smart Search > Settings.  You will find your Smart Search AI MCP URL here.  Currently, this shows your GraphQL endpoint. This is correct and what you want to see.

What you need to do is manually remove the /graphql and add /mcp.

So your endpoint should look like this after replacing it:

https://your-wpenginesite-0999A-atlassearch-fkdfjckuaa-uc.a.run.app/mcp

4. Next, navigate to Configuration, select the Hybrid card, and add the `post_content` and `post_title` fields in the Semantic settings section. We are going to use this field as our AI-powered field for similarity searches. Make sure to hit Save Configuration afterward.

5. After saving the configuration, head on over to the Index data page, then click Index Now.  It will give you this success message once completed :

6. Create an API account on Google Gemini (Or whatever AI model you choose, e.g., OpenAI API).  Once created, navigate to your project’s dashboard. If you are using Gemini API, go to the Google AI Studio. In your project’s dashboard, go to API Keys.  You should see a page like this:

Generate a new key, copy, and save your API key because we will need this later.  The API key is free on Google Gemini,  but the free tier has limits.

7.  Head over to your terminal or CLI and create a new Next.js project by pasting this utility command in:

`npx create-next-app@latest name-of-your-app`

You will receive prompts in your terminal asking you how you want your Next.js app scaffolded.  Answer them accordingly:

Would you like to use TypeScript? Yes
Wold you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use the `src/` directory? Yes
Would you like to use App Router? Yes
Would you like to customize the default import alias (@/*)? No

Once your Next.js app is created, you will need to install the dependencies needed to ensure our app works.  Copy and paste this command in your terminal:

npm install @ai-sdk/google react-icons react-markdown @modelcontextprotocol/sdk @ai-sdk/react @ai

Note: We are using Google’s AI sdk for this article. Please refer to the docs in relation to whatever AI model you choose.  You can download their npm package.

Once the Next project is done scaffolding, cd into the project and then open up your code editor.

8. In your Next project, create a  `.env.local` file with the following environment variables:

GOOGLE_GENERATIVE_AI_API_KEY="<your key here>"(if you chose another AI model, you can name this key whatever you want)

AI_TOOLKIT_MCP_URL="<your smart search mcp url here>"

Here is the link to the final code repo so you can check step by step and follow along.

Calling The WP Engine Smart Search AI MCP Server From Next.js

The first thing we need to do is set up the request to Smart Search AI MCP with the Vercel AI SDK.   Create a file in the `src/app` directory called `api/chat/route.ts`.  Copy the code below and paste it into that file:

// IMPORTANT! Set the runtime to edge
export const runtime = "edge";

import {
  convertToCoreMessages,
  experimental_createMCPClient,
  Message,
  streamText,
} from "ai";
import { createGoogleGenerativeAI } from "@ai-sdk/google";

import { weatherTool } from "@/app/utils/tools";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const httpTransport = new StreamableHTTPClientTransport(
  new URL(process.env.AI_TOOLKIT_MCP_URL || "http://localhost:8080/mcp")
);

const client = await experimental_createMCPClient({
  transport: httpTransport,
});

/**
 * Initialize the Google Generative AI API
 */
const google = createGoogleGenerativeAI();

export async function POST(req: Request) {
  try {
    const aiTkTools = await client.tools();
    const { messages }: { messages: Array<Message> } = await req.json();

    const coreMessages = convertToCoreMessages(messages);

    const smartSearchPrompt = `
    - You can use the 'search' tool to find information relating to tv shows.
      - WP Engine Smart Search is a powerful tool for finding information about TV shows.
      - After the 'smartSearchTool' provides results (even if it's an error or no information found)
      - You MUST then formulate a conversational response to the user based on those results but also use the tool if the users query is deemed plausible.
        - If search results are found, summarize them for the user. 
        - If no information is found or an error occurs, inform the user clearly.`;

    const systemPromptContent = `
    - You are a friendly and helpful AI assistant 
    - You can use the 'weatherTool' to provide current weather information for a specific location.
    - Do not invent information. Stick to the data provided by the tool.`;

    const response = streamText({
      model: google("models/gemini-2.0-flash"),
      system: [smartSearchPrompt, systemPromptContent].join("\n"),
      messages: coreMessages,
      tools: {
        // smartSearchTool,
        weatherTool,
        ...aiTkTools,
      },
      onStepFinish: async (result) => {
        // Log token usage for each step
        if (result.usage) {
          console.log(
            `[Token Usage] Prompt tokens: ${result.usage.promptTokens}, Completion tokens: ${result.usage.completionTokens}, Total tokens: ${result.usage.totalTokens}`
          );
        }
      },
      maxSteps: 5,
    });
    // Convert the response into a friendly text-stream
    return response.toDataStreamResponse({});
  } catch (e) {
    throw e;
  }
}

This Edge API route wires your chat endpoint to both Google’s Gemini (via the Vercel AI SDK) and the Smart Search AI MCP server. It first creates a streaming-capable MCP HTTP transport pointed at AI_TOOLKIT_MCP_URL, builds an MCP client, and fetches the server-advertised tools at request time (client.tools()).

Incoming chat messages from the client are normalized with convertToCoreMessages, and two concise system prompts instruct the model on how to use tools: a “search” tool (backed by WP Engine Smart Search via MCP) and a local weatherTool. The prompts emphasize not inventing facts and summarizing search results (including the “no results” case).

With that context, streamText runs gemini-2.0-flash, exposes weatherTool plus all MCP tools (…aiTkTools) to the model, and streams the assistant’s reply back to the browser. The SDK may invoke tools during reasoning (up to maxSteps: 5). After each step, the handler logs token usage for basic observability. 

Finally, toDataStreamResponse returns a chunked HTTP response so the UI can render tokens as they arrive—giving you a real-time, tool-augmented chat experience that queries Smart Search through your MCP server when needed.

Create UI Components For The Chat Interface

In this section, let’s create our components to render the UI.

Chat.tsx

In the `src/app` directory, create a `components` folder.  Then create a `Chat.tsx` file.  Copy and paste this code block into that file:

"use client";

import React, { ChangeEvent } from "react";
import Messages from "./Messages";
import { Message } from "ai/react";
import LoadingIcon from "../Icons/LoadingIcon";
import ChatInput from "./ChatInput";

interface Chat {
  input: string;
  handleInputChange: (e: ChangeEvent<HTMLInputElement>) => void;
  handleMessageSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
  messages: Message[];
  status: "submitted" | "streaming" | "ready" | "error";
}

const Chat: React.FC<Chat> = ({
  input,
  handleInputChange,
  handleMessageSubmit,
  messages,
  status,
}) => {
  return (
    <div id="chat" className="flex flex-col w-full mx-2">
      <Messages messages={messages} />
      {status === "submitted" && <LoadingIcon />}
      <form
        onSubmit={handleMessageSubmit}
        className="ml-1 mt-5 mb-5 relative rounded-lg"
      >
        <ChatInput input={input} handleInputChange={handleInputChange} />
      </form>
    </div>
  );
};

export default Chat;


This file defines a client-side React Chat component that ties together your message list, input field, and loading indicator. It declares a Chat props interface—containing the current input value, change and submit handlers, the array of chat messages, and a status flag—and uses those props to control its rendering. 

Inside the component, it first renders the <Messages> list to show the conversation history. If the status is “submitted”, it displays a <LoadingIcon> spinner to indicate that a response is pending. Finally, it renders a <form> wrapping a <ChatInput> component wired to the provided input value and change handler, so users can type and submit new messages.

Messages Component

Staying in the `src/app/components` directory, create a Messages.tsx file.  Copy and paste this code block in:

import { Message } from "ai";
import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";

export default function Messages({ messages }: { messages: Message[] }) {
  const messagesEndRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);
  return (
    <div
      className="border-1 border-gray-100 overflow-y-scroll flex-grow flex-col justify-end p-1"
      style={{ scrollbarWidth: "none" }}
    >
      {messages.map((msg, index) => (
        <div
          key={index}
          className={`${
            msg.role === "assistant" ? "bg-green-500" : "bg-blue-500"
          } my-2 p-3 shadow-md hover:shadow-lg transition-shadow duration-200 flex slide-in-bottom bg-blue-500 border border-gray-900 message-glow`}
        >
          <div className="ml- rounded-tl-lg  p-2 border-r flex items-center">
            {msg.role === "assistant" ? "🤖" : "🧒🏻"}
          </div>
          <div className="ml-2 text-white">
            <ReactMarkdown>{msg.content}</ReactMarkdown>
          </div>
        </div>
      ))}
      <div ref={messagesEndRef} />
    </div>
  );
}

The Messages component renders a scrollable list of chat messages, automatically keeping the view scrolled to the latest entry. It accepts a messages prop (an array of Message objects) and uses a ref to an empty <div> at the bottom; a useEffect hook watches for changes to the messages array and calls scrollIntoView on that ref so new messages smoothly come into view. 

Each message is wrapped in a styled <div> whose background color and avatar icon depend on the message’s role (“assistant” vs. “user”), and the text content is rendered via ReactMarkdown to support Markdown formatting.

Chat Input Component

Lastly, staying in the `components/Chat` directory,  we have the chat input.  Create a `ChatInput.tsx` file and copy and paste this code block in:

import { ChangeEvent } from "react";
import SendIcon from "../Icons/SendIcon";

interface InputProps {
  input: string;
  handleInputChange: (e: ChangeEvent<HTMLInputElement>) => void;
}

function Input({ input, handleInputChange }: InputProps) {
  return (
    <div className="bg-gray-800 p-4 rounded-xl shadow-lg w-full max-w-2xl mx-auto">
      <input
        type="text"
        value={input}
        onChange={handleInputChange}
        placeholder={"Ask Smart Search about TV shows..."}
        className="w-full bg-transparent text-gray-200 placeholder-gray-500 focus:outline-none text-md mb-3"
      />
      <div className="flex">
        <button
          type="submit"
          className="p-1 hover:bg-gray-700 rounded-md transition-colors ml-auto"
          aria-label="Send message"
          disabled={!input.trim()}
        >
          <SendIcon />
        </button>
      </div>
    </div>
  );
}

export default Input;

This file exports an Input component that renders a styled text field and send button for your chat UI. It takes an input string and a handleInputChange callback to keep the input controlled, showing a placeholder prompt (“Ask Smart Search about TV shows…”). The send button, decorated with a SendIcon, is disabled when the input is empty or just whitespace.

Update the page.tsx Template


We need to modify the src/app/page.tsx file to add the Chat component to the page.  In the page.tsx file, copy and paste this code:

"use client";
import Chat from "./components/Chat/Chat";
import { useChat } from "@ai-sdk/react";
import { useEffect } from "react";

const Page: React.FC = () => {
  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    setMessages,
    status,
  } = useChat();

  useEffect(() => {
    if (messages.length < 1) {
      setMessages([
        {
          role: "assistant",
          content: "Welcome to the Smart Search chatbot!",
          id: "welcome",
        },
      ]);
    }
  }, [messages, setMessages]);

  return (
    <div className="flex flex-col justify-between h-screen bg-white mx-auto max-w-full">
      <div className="flex w-full flex-grow overflow-hidden relative bg-slate-950">
        <Chat
          input={input}
          handleInputChange={handleInputChange}
          handleMessageSubmit={handleSubmit}
          messages={messages}
          status={status}
        />
      </div>
    </div>
  );
};

export default Page;


This file defines our page component that leverages the useChat hook from the @ai-sdk/react package to manage chat state, including messages, input text, submission handler, and status. 
Upon initial render, a useEffect hook checks if there are no messages and injects a default assistant greeting. The component returns a full-viewport flexbox layout with a styled background area in which it renders the Chat component, passing along the chat state and handlers.

Update The layout.tsx File With Metadata

We need to add metadata to our layout.  Copy and paste this code block into the `src/app/layout.tsx` file:

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Smart Search RAG",
  description: "Lets make a chatbot with Smart Search",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  );
}

This file configures the global layout and metadata for the app: it imports global styles, loads the Inter font, and sets the page title and description. The default RootLayout component wraps all page content in <html> and <body> tags, applying the Inter font’s class to the body.

CSS Note: The last thing to add for the styling is the globals.css file. Visit the code block here and copy and paste it into your project.

Test The ChatBot’s Dynamism


The chatbot should be completed and testable in this state. In your terminal run `npm run dev` and navigate to http://localhost:3000. Try asking the chatbot a few questions. 

After you ask it a few questions related to your WordPress content, ask it something about a subject that is not in your WordPress content. The AI will try to fetch what you asked for knowing what tooling it has via MCP. It will know that the content does not exist in your WordPress site via Smart Search.

Now, try adding a new post with a title and content. It could be any topic. Publish the post and then ask the chatbot about the subject. It should give you the relevant content you are asking for in natural language.

You should see this experience in your browser: 

Conclusion

We hope this article helped you understand how to create a chatbot with WP Engine’s Smart Search AI MCP server headless WordPress!  Stay tuned for the next article on using this in traditional WordPress!!

As always, we’re super stoked to hear your feedback and learn about the headless projects you’re working on, so hit us up in the Headless WordPress Discord!

The post Implement WP Engine’s Smart Search AI Model Context Protocol (MCP) Server in Headless WordPress appeared first on Builders.

]]>
https://wpengine.com/builders/wp-engine-smart-search-mcp-in-headless-wp/feed/ 0
Astro + WordPress: Post Previews https://wpengine.com/builders/astro-wordpress-post-previews/ https://wpengine.com/builders/astro-wordpress-post-previews/#respond Mon, 18 Aug 2025 21:12:11 +0000 https://wpengine.com/builders/?p=31956 Historically, previews for headless WordPress have been quite complicated. Gatsby and Faust.js have both implemented their own solutions, but required specific buy-in on those solutions. To alleviate this, our headless […]

The post Astro + WordPress: Post Previews appeared first on Builders.

]]>
Historically, previews for headless WordPress have been quite complicated. Gatsby and Faust.js have both implemented their own solutions, but required specific buy-in on those solutions. To alleviate this, our headless OSS team released the HWP Previews plugin for headless WordPress that sets you up to do previews without using Faust or Gatsby. Now, you don’t have to use Faust to give your publishers the preview experience they expect in WordPress.

This plugin overrides WordPress’s default preview behavior and allows you to control how previews are requested (URL, path, query params, etc.). The plugin is in beta, so we’d love to hear about any missing features or bugs.

Since the HWP Previews plugin covers our needs on the WP side, this article will cover implementing the functionality on the framework side, i.e., Astro. Join me as we dive into WPGraphQL, authentication, and previews!

Goal

  1. We want the same components/code that renders our production pages to render previews so they’re identical.
  2. We want the WP experience to be seamless for content creators.

Requirements

Astro

  1. Needs to know whether a request is for a preview or production page
  2. Needs to know what content is being previewed
  3. Needs to be able to render the preview on demand
  4. Needs to use the same template/components/queries as production pages
  5. Needs to be able to authenticate the preview request

WordPress

  1. Needs to know where our front-end is for previews(domain/path)
  2. Needs to be able to know how to request a preview for a given post/page (path/queries/headers/etc)
  3. Needs to leverage the existing “preview” links/buttons/popups provided by native previews to keep the experience seamless.

Pesky Details

Auth

What you’re not seeing in the URL is also the authentication that’s happening. This preview URL will redirect the user to the WordPress login if they’re not already authenticated. Unpublished posts are by default non-public. You must be authenticated and authorized to view them. This holds true in WPGraphQL; if you query an unpublished post, it’ll return null unless you authenticate your GraphQL query. We’ll need to authenticate users and make sure any GraphQL requests receive proper Authentication headers for preview routes.

Database ID

So why doesn’t WordPress use a URL like https://mywordpresssite.com/blog/hello-world/?preview=true? After all, this URL is unique to the post, and the query param tells WordPress to render the draft version. If only it were that simple!

WordPress doesn’t assign a URI to a post until after it has been published. This means drafts and scheduled posts don’t have a dedicated URI. They may have a slug, but this isn’t necessarily unique in WordPress land. Thus, to correctly render previews, we must handle routing and data fetching based on the databaseId, not the URI. This will come up in several ways later, but for now, know that this is a constraint of WordPress we must adapt to.

Strategy

Like in any headless WordPress setup, we’ll need to start with working routing and data fetching. For this article, I’ll be starting from where we left off in the Routing and GraphQL article and adding on previews. This means we’ll be using URQL for data fetching and the template hierarchy for routing.

SSR

First, we need SSR. Our original [...uri].astro catch-all route was static. We have two options: convert it to SSR or add a dedicated /preview/ route that is SSR. For this example, I’ve opted to convert my catch-all route to SSR.

Detect Preview

Next, like WordPress, we need to detect whether we need to authenticate the request and fetch preview data. The preview=true query parameter does this for us. We’ll use this to detect previews and handle them.

Database ID

As we discussed before, the database ID is required by WordPress for previews. In my example, I get this by having the Previews plugin pass this as a query param: post_id={ID}

Auth

The goal of this article is to show you previews, not implement authentication. Because of that, I’ve opted for the simplest possible authentication method, which is not secure. I’ve opted to hard-code my admin credentials in my code and use them for Basic authentication

Note: I can do this because the WordPress server used in this example is not public; its entire DB and front-end example are running on your computer if you start it from the repo. If you’re going to implement previews, you’ll need to implement proper authentication if you don’t want a security breach. I’d highly recommend the WPGraphQL Headless Login plugin by Dovid Levine.

Configuring WordPress

Selecting to preview a post or page in WordPress results in a URL path that will look something like https://mywordpresssite.com/?preview=true&p=23, with p being the post’s database ID.

What needs to change here? First, our front-end is not on the WordPress server; we need to tell previews to go to our JS framework. This is likely your production server, but static site builders like Gatsby may require a dedicated preview server. Others may require a dedicated path, query parameters, or headers.

Finally, WordPress renders the appropriate PHP template at this route. To keep our experience seamless, I’d rather the content creators see the headless front-end here, not get kicked out to a new tab or have to find their way back into WordPress admin.

This means we need to customize the URL that we’ll route to when clicking “preview” and customize the ?preview=true behavior to embed an iframe of our front-end. The good news is that this is exactly what the HWP Previews plugin does! 

Building It out

Alright, now that WordPress is configured and our basic strategy is in place, let’s start implementing this in Astro.

Catch-all Route

Our first changes will be in the catch-all route, where we fetch the template. We’ll start by capturing and storing our preview and post_id search params.

const isPreview = Astro.url.searchParams.get("preview") === "true";
const postId = Astro.url.searchParams.get("post_id") || undefined;

We’ll also want to store this search parameter for later use. Because we’re using Astro’s rewrite functionality, this param gets stripped from the URL accessed by templates. Thus, we’ll save this for use later.

// Locals is an Astro pattern for sharing route data.
Astro.locals.isPreview = isPreview;

Authentication

I’ve told you I did some really basic things. Are you ready for it?

export const authHeaders = (isPreview) => {
  return isPreview
    ? {
        Authorization: `Basic ${Buffer.from(
          `admin:password`
        ).toString("base64")}`,
      }
    : undefined;
};

As you can see, if isPreview is true, we add the Authorization header; otherwise, we don’t. This is used in combination with a great feature of the URQL GraphQL client.

const response = await client.query(QUERY, VARIABLES,{
    fetchOptions: {
      headers: {
        ...authHeaders(isPreview),
      },
    },
  }
);

On top of taking the query and variables for a query, the third parameter of URQL’s query function takes a config. This is the identical config you can pass when creating the client. That means I can create a single client with good defaults and override as needed. I don’t have to select between any number of clients. I can use a single client and add headers and other config as needed. 

Template Hierarchy

In the last article, we built the uriToTemplate function for handling our template-hierarchy routing. Now that we want to implement previews, we need this to handle the database ID or URI to the template. If you happened to pay attention to the original seed query we were using, you would have noticed that because we copied it from Faust, the query was already set up to handle this.

query GetSeedNode(
    $id: ID! = 0
    $uri: String! = ""
    $asPreview: Boolean = false
  ) {
    ... on RootQuery @skip(if: $asPreview) {
      nodeByUri(uri: $uri) {
        __typename
        ...GetNode
      }
    }

    ... on RootQuery @include(if: $asPreview) {
      contentNode(id: $id, idType: DATABASE_ID, asPreview: true) {
        __typename
        ...GetNode
      }
    }
  }

Thus, I updated my getSeedQuery function to handle the additional variables. Auth was also handled here.

export async function getSeedQuery(variables) {
  return client.query(SEED_QUERY, variables, {
    fetchOptions: {
      headers: {
        ...authHeaders(variables.asPreview),
      },
    },
  });
}

Finally, I updated uriToTemplate to idToTemplate, which handles both uri and databaseId.

export async function idToTemplate(
  options: ToTemplateArgs
): Promise<TemplateData> {
  const id = "id" in options ? options.id : undefined;
  const uri = "uri" in options ? options.uri : undefined;
  const asPreview = "asPreview" in options ? options.asPreview : false;

  if (asPreview && !id) {
    console.error("HTTP/400 - preview requires database id");
    return returnData;
  }

  const { data, error } = await getSeedQuery({ uri, id, asPreview });

  //...
}

Finally, we update the call to this function from the catch-all route.

const results = await idToTemplate({ uri, asPreview: isPreview, id: postId });

Updating Templates

You’d be forgiven for thinking we’re done. Remember that pesky thing I mentioned about having to use databaseIds for preview queries? Well, we now have to update our templates to do this.

While we could make our templates use the @skip and @include pattern like the seed query…there is just no point. The seed query handled this complexity for us and returned a bunch of data that we used to select a template. That data included the database ID. We can now use that for all further queries instead of the URI!

Let’s start by grabbing those isPreview and databaseId variables so they’re handy.

const isPreview = Astro.locals.isPreview;
const databaseId = Astro.locals.templateData?.databaseId;

Like with our seed query, we will also need to add authentication when appropriate.

const { data, error } = await client.query(
  gql`
    #...
  `,
  {
    databaseId,
    isPreview,
  },
  {
    fetchOptions: {
      headers: {
        ...authHeaders(isPreview),
      },
    },
  }
);

Next, we need to update our query. Previously, we used nodeByUri for all of our queries. This works great, but it doesn’t accept database IDs or support returning preview data. Thus, for posts and pages, we need to use contentNode.

const { data, error } = await client.query(
  gql`
    query singleTemplatePageQuery(
      $databaseId: ID!
      $isPreview: Boolean = false
    ) {
      contentNode(
        id: $databaseId
        idType: DATABASE_ID
        asPreview: $isPreview
      ) {
        id
        uri
        ... on NodeWithTitle {
          title
        }
        ... on NodeWithContentEditor {
          content
        }
        ... on Post {
          categories {
            nodes {
              name
              uri
            }
          }
          tags {
            nodes {
              name
              uri
            }
          }
        }
      }
    }
  `,
  {
    databaseId,
    isPreview,
  },
  {
    fetchOptions: {
      headers: {
        ...authHeaders(isPreview),
      },
    },
  }
);

For this change, I didn’t have to alter any of the actual query that defines the returned data. I also updated the Astro html template to access data.contentNode instead of data.nodeByUri.

Finally, I added a quick check to validate that I got a post back. If you’re not aware, having no or incorrect credentials for a query in GraphQL doesn’t result in an HTTP/401 error, but a null value. I’ll add a check to return HTTP/404 if the value is null. This handles incorrect database IDs and unauthorized queries.

if (!data.post) {
  console.error("HTTP/404 - Not Found in WordPress:", databaseId);
  return Astro.rewrite("/404");
}

It works!

Previews! We’ve implemented one of the biggest missing features of headless WordPress. HWP Previews did all the heavy lifting on the WP side, and we took what it provided to render the posts. Our content creators can now publish with confidence, knowing exactly what their work will look like on the front end!

I’m excited to have this plugin available for folks to build custom preview experiences outside of Gatsby and Faust. What are you going to build with it? Come join our Headless WordPress Discord and let us know!

The post Astro + WordPress: Post Previews appeared first on Builders.

]]>
https://wpengine.com/builders/astro-wordpress-post-previews/feed/ 0
How to Create a Headless E-Commerce Search Experience With WP Engine’s Smart Search AI and Nuxt.js https://wpengine.com/builders/how-to-create-a-headless-e-commerce-search-experience-with-wp-engines-smart-search-ai-and-nuxt-js/ https://wpengine.com/builders/how-to-create-a-headless-e-commerce-search-experience-with-wp-engines-smart-search-ai-and-nuxt-js/#respond Fri, 08 Aug 2025 16:40:53 +0000 https://wpengine.com/builders/?p=31950 Have you ever tried to buy something on a website only to have its poor search feature send you somewhere else? My stoke for finding the perfect rock climbing, coding, […]

The post How to Create a Headless E-Commerce Search Experience With WP Engine’s Smart Search AI and Nuxt.js appeared first on Builders.

]]>

Have you ever tried to buy something on a website only to have its poor search feature send you somewhere else? My stoke for finding the perfect rock climbing, coding, or running gear definitely drops when I can’t easily find what I’m looking for.

The search feature is an essential tool on any e-commerce site for converting visitors into customers. It helps users find and purchase products quickly and efficiently.

This is where WP Engine’s Smart Search AI steps in. It’s a product for WP Engine customers that replaces WordPress’s built-in search with an intelligent, AI-driven engine for both traditional and headless WordPress applications. Smart Search AI guides visitors to the most relevant content using semantic understanding to surface better results, even for custom post types.

In this step-by-step guide, I will show you how to create a full headless WordPress e-commerce search experience with WooCommerce, WPGraphQL, and WP Engine Smart Search AI.  By the end of this article, you will have created a starter e-commerce site with search functionality from start to finish.

If you prefer the video version of this article, you can access it here:

Prerequisites

To benefit from this article, you should be familiar with the basics of working with the command line, headless WordPress development, Nuxt.js, and the WP Engine User Portal.

Steps For Setting Up:

1. Set up an account on WP Engine and get a WordPress install running.  Log in to your WP Admin. Alternatively, if you are not an existing customer of WP Engine, you can get a free headless platform sandbox account here to give it a try.

2. Once in WP Admin, go to Plugins in the left sidebar, click the Add New button, search for the WooCommerce* plugin, and install it. Follow the same process to install the WPGraphQL plugin. Once both plugins are installed, activate them.

Note: Don’t forget to save your WPGraphQL endpoint, which you can access on the WPGraphQL settings page:

3. Next, go to the WooGraphQL releases page on the GitHub repo and download the latest version.  Once you download the latest version, go back to your WP Admin and upload it to the plugins page.

4. We’ll run a test to ensure that WooCommerce data can be accessed via GraphQL next.  First, we need to add product data to our WooCommerce store.  In the left sidebar, go to Products > Add New Product:

Once you click on that, it will take you to a general Products page that shows all your products.  At the top of the page, you will have the option to Add New Product, Import, or Export.  This is where you can add, edit, and import products. Click on Import:

This is where you can add all my dummy product data by going to this .csv file in my repo, downloading it, then uploading it into your WooCommerce Import Products page here:

For this example, I added a product name, product description, regular price, SKU, product tag, product category, and product image.


5. Add a Smart Search license. Refer to the docs here to add a license. Contact our sales department for a free trial demo.

6. In the WP Admin, go to WP Engine Smart Search > Settings.  You will find your Smart Search URL and access token here.  Copy and save it.  We will need it for our environment variables for the frontend.  You should see this page:

7. Next, navigate to Configuration, select the Semantic card, and add the post_content, post_title, and post_excerpt fields in the Semantic settings section. We are going to use these fields as our AI-powered field for similarity searches. Make sure to hit Save Configuration afterward.

8. After saving the configuration, head on over to the Index data page, then clickIndex Now”It will give you this success message once completed :


9. Now that we have indexed our data into Smart Search, let’s make sure it works.  Head over the the GraphQL IDE in your WP Admin. You can either access this via the left sidebar or the menu bar at the top of the page.  Copy and paste the query below into the IDE:

query GetProducts($first: Int = 10) {
  products(first: $first) {
    edges {
      node {
        name
        description
        image {
          sourceUrl
          altText
        }
      }
    }
  }
}

This is a simple query that is asking for the first 10 products.  It should give you the name, description, and image data of your products.  Hit play, and you should get the results back:

Stoked!!! It works!

10. We need to set the frontend up now.  The Nuxt.js frontend boilerplate will contain a project that already renders a home page with products and links to those product details pages.  Clone down the Nuxt repo starting point by copying and pasting this command in your terminal

npx degit Fran-A-Dev/smart-search-headlesswp-ecomm#starting-point-boilerplate my-project


Once you clone it down, navigate into the directory and install the project dependencies:

cd my-project
npm install


11. Create a .env.local file inside the root of the Nuxt project. Open that file and paste in these environment variables (The environment variables are the ones you saved from steps 2 and 6) :

NUXT_PUBLIC_WORDPRESS_URL="<your WP url here>"
NUXT_PUBLIC_SMART_SEARCH_URL="<your smart search url here>"
NUXT_PUBLIC_SMART_SEARCH_TOKEN="<your smart search access token here>"


12. Next, let’s update how our Nuxt app will build and run the site.  Go to your nuxt.config.ts file in the root and update it accordingly:

export default defineNuxtConfig({
  compatibilityDate: "2024-11-01",
  devtools: { enabled: process.env.NODE_ENV === "development" },
  modules: ["@nuxtjs/tailwindcss", "@nuxt/image"],

  nitro: {
    compressPublicAssets: true,
  },

  css: ["~/assets/css/main.css"],

  build: {
    transpile: process.env.NODE_ENV === "production" ? ["vue"] : [],
  },
  image: {
    domains: [
      new URL(process.env.NUXT_PUBLIC_WORDPRESS_URL || "").hostname,
    ].filter(Boolean),
    quality: 80,
    format: ["webp", "jpg", "png"],
  },
  app: {
    head: {
      title: "Nuxt headlesswp e-commerce",
      meta: [{ name: "description", content: "Nuxt headlesswp e-commerce" }],
      link: [
        {
          rel: "stylesheet",
          href: "https://fonts.googleapis.com/icon?family=Material+Icons",
        },
      ],
    },
  },
  runtimeConfig: {
    public: {
      wordpressUrl: "",
      smartSearchUrl: "",
      smartSearchToken: "",
    },
  },
});


We are done with the setup steps to create the boilerplate starting point.  In your terminal, run npm run dev and visit http://localhost:3000 to make sure it works.  You should see this:

And when you navigate to a product detail page by clicking on a details link, you should see the detail page:

Wrap The Smart Search Endpoint

The first thing we need to do is wrap our GraphQL fetch logic—both against the WP Engine Smart Search endpoint and our WordPress GraphQL API—into a single reusable function. Create a folder called composables at the root.  In that folder, create a file called useSmartSearch.js and paste in the code below:

export const useSmartSearch = () => {
  const config = useRuntimeConfig();
  const {
    public: { smartSearchUrl, smartSearchToken, wordpressUrl },
  } = config;

  
  const _post = async ({ url, token, query, variables }) => {
    if (!url) throw new Error("URL not configured");
    const headers = { "Content-Type": "application/json" };
    if (token) headers.Authorization = `Bearer ${token}`;
    try {
      return await $fetch(url, {
        method: "POST",
        headers,
        body: { query, variables },
      });
    } catch (err) {
      if (process.dev) {
        console.error("GraphQL error:", err);
      }
      throw err;
    }
  };

  
  const getContext = (message, field = "post_content", minScore = 0.8) =>
    _post({
      url: smartSearchUrl,
      token: smartSearchToken,
      query: `query GetContext($message: String!, $field: String!, $minScore: Float!) {
        similarity(input: { nearest: { text: $message, field: $field }, minScore: $minScore }) {
          total
          docs { id data score }
        }
      }`,
      variables: { message, field, minScore },
    });

  
  const searchProducts = (
    searchQuery,
    { limit = 10, strictMode = false, filter = null } = {}
  ) => {
    
    const semanticSearchConfig = strictMode
      ? "" 
      : 'semanticSearch: { searchBias: 10, fields: ["post_title", "post_content"] }';

    let finalFilter = "post_type:product";
    if (filter) {
      finalFilter = `${finalFilter} AND ${filter}`;
    }

    return _post({
      url: smartSearchUrl,
      token: smartSearchToken,
      query: `query SearchProducts($query: String!, $limit: Int, $filter: String!) {
        find(
          query: $query
          limit: $limit
          filter: $filter
          ${semanticSearchConfig}
        ) {
          total
          documents { id score data }
        }
      }`,
      variables: { query: searchQuery, limit, filter: finalFilter },
    });
  };

  
  const getProductDetails = (productIds) =>
    _post({
      url: wordpressUrl,
      token: null,
      query: `query GetProductDetails($ids: [Int]!) {
        products(where: { include: $ids }) {
          edges { 
            node { 
              databaseId 
              name 
              image { sourceUrl altText } 
              ... on ProductWithPricing { regularPrice } 
            } 
          }
        }
      }`,
      variables: { ids: productIds },
    });

  return { getContext, searchProducts, getProductDetails };
};

This composable wraps all interactions with WP Engine’s Smart Search and your WordPress GraphQL endpoint.

It reads URLs and tokens from Nuxt’s runtime config, provides a private _post helper for sending GraphQL requests via $fetch, and exposes three methods: getContext for server-side semantic similarity searches, searchProducts for both AI-driven semantic queries and strict filtering of products (by toggling strictMode or supplying a custom filter string), and getProductDetails to fetch full product data—including images and pricing—directly from WPGraphQL.

Create Search Logic

The next piece brings together our Smart Search and WPGraphQL calls into a single logic layer. Create a file at composables/useSearchLogic.js and paste in the code below:

import { useSmartSearch } from "./useSmartSearch";
import { ref } from "vue";

export const useSearchLogic = () => {
  const { searchProducts, getProductDetails } = useSmartSearch();
  const resultsLimit = ref(20);

  const mapBasicResults = (documents) =>
    documents.map(({ data, score }) => ({
      id: data.ID,
      title: data.post_title,
      description: data.post_content,
      score,
      image: "",
      price: 0,
    }));

  const performSearch = async (query) => {
    if (!query || !query.trim()) {
      return { success: false, error: "Empty query" };
    }

    const startTime = Date.now();

    try {
      const { data } = await searchProducts(query, {
        limit: Number(resultsLimit.value)
      });

      if (!data?.find) {
        throw new Error("Invalid search response");
      }

      const basic = mapBasicResults(data.find.documents);
      const detailed = await fetchCompleteProductData(basic);
      const searchTime = Date.now() - startTime;

      return {
        success: true,
        results: detailed,
        total: data.find.total,
        searchTime,
        query: `Text search: "${query}"`,
      };
    } catch (error) {
      if (process.dev) {
        console.error("Search error:", error);
      }
      return {
        success: false,
        error: `Search failed: ${error.message || "Please try again."}`,
      };
    }
  };

  const performActivitySearch = async (activityValue, priceFilter = null) => {
    if (!activityValue || !activityValue.trim()) {
      return { success: false, error: "No activity selected" };
    }

    const startTime = Date.now();

    try {
      let query = `product_cat.name.keyword:"${getActivityLabel(
        activityValue
      )}"`;

      const { data } = await searchProducts(query, {
        limit: Number(resultsLimit.value),
        strictMode: true, 
      });

      if (!data?.find) {
        throw new Error("Invalid search response");
      }

      const basic = mapBasicResults(data.find.documents);
      const detailed = await fetchCompleteProductData(basic);

      let filteredResults = detailed;
      if (
        priceFilter &&
        (priceFilter.min !== undefined || priceFilter.max !== undefined)
      ) {
        filteredResults = detailed.filter((product) => {
          const price = product.price || 0;
          const { min = 0, max = Infinity } = priceFilter;
          return price >= min && price <= max;
        });
      }

      const searchTime = Date.now() - startTime;

      return {
        success: true,
        results: filteredResults,
        total: filteredResults.length,
        searchTime,
        query: `Activity: ${getActivityLabel(activityValue)}${
          priceFilter
            ? ` | Price: $${priceFilter.min || 0} - $${
                priceFilter.max || "max"
              }`
            : ""
        }`,
      };
    } catch (error) {
      if (process.dev) {
        console.error("Activity search error:", error);
      }
      return {
        success: false,
        error: `Search failed: ${error.message || "Please try again."}`,
      };
    }
  };

   const performPriceOnlySearch = async (
    { min, max },
    activityFilter = null
  ) => {
    const startTime = Date.now();

    try {
      let query = activityFilter
        ? `product_cat.name.keyword:"${getActivityLabel(activityFilter)}"`
        : "*";

      const { data } = await searchProducts(query, {
        limit: Number(resultsLimit.value),
        strictMode: true,
      });

      if (!data?.find) {
        throw new Error("Invalid search response");
      }

      const basic = mapBasicResults(data.find.documents);
      const detailed = await fetchCompleteProductData(basic);

    
      const filteredResults = detailed.filter((product) => {
        const price = product.price || 0;
        return price >= min && price <= max;
      });

      const searchTime = Date.now() - startTime;

      return {
        success: true,
        results: filteredResults,
        total: filteredResults.length,
        searchTime,
        query: `${
          activityFilter
            ? `Activity: ${getActivityLabel(activityFilter)} | `
            : ""
        }Price: $${min} - $${max}`,
      };
    } catch (error) {
      if (process.dev) {
        console.error("Price search error:", error);
      }
      return {
        success: false,
        error: `Search failed: ${error.message || "Please try again."}`,
      };
    }
  };

  const fetchCompleteProductData = async (products) => {
    if (!products.length) return [];

    try {
      const productMap = new Map();
      products.forEach((prod) => {
        productMap.set(prod.id, prod);
      });

      const productIds = Array.from(productMap.keys());
      const response = await getProductDetails(productIds);

      const edges = response?.data?.products?.edges || [];

      const graphqlDataMap = new Map();
      edges.forEach((edge) => {
        if (edge?.node?.databaseId) {
          graphqlDataMap.set(edge.node.databaseId, edge.node);
        }
      });

      const enrichedProducts = [];
      for (const [productId, basicProduct] of productMap) {
        const graphqlNode = graphqlDataMap.get(productId);

        if (!graphqlNode) {
          enrichedProducts.push({
            ...basicProduct,
            image: "",
            price: 0,
            formattedPrice: "$0.00",
            hasImage: false,
            isAvailable: false,
          });
          continue;
        }

        const imageData = graphqlNode.image;
        const imageUrl = imageData?.sourceUrl || "";
        const imageAlt = imageData?.altText || basicProduct.title || "";

        const rawPrice = graphqlNode.regularPrice || "";
        let priceValue = 0;
        let formattedPrice = "$0.00";

        if (rawPrice) {
          const numericPrice = rawPrice.replace(/[^0-9.]/g, "");
          priceValue = numericPrice ? parseFloat(numericPrice) : 0;

          if (priceValue > 0) {
            formattedPrice = new Intl.NumberFormat("en-US", {
              style: "currency",
              currency: "USD",
            }).format(priceValue);
          }
        }

        const productName = graphqlNode.name || basicProduct.title;
        const productSlug = graphqlNode.slug || "";
        const productDescription =
          graphqlNode.description || basicProduct.description || "";

        enrichedProducts.push({
          ...basicProduct,
          title: productName,
          description: productDescription,
          slug: productSlug,
          image: imageUrl,
          imageAlt,
          hasImage: Boolean(imageUrl),
          price: priceValue,
          formattedPrice,
          rawPrice,
          isAvailable: priceValue > 0,
          hasCompleteData: true,
        });
      }

      return enrichedProducts;
    } catch (error) {
      if (process.dev) {
        console.error("Error fetching product details:", error);
      }

      return products.map((prod) => ({
        ...prod,
        image: "",
        price: 0,
        formattedPrice: "$0.00",
        hasImage: false,
        isAvailable: false,
        hasCompleteData: false,
        error: "Failed to fetch complete data",
      }));
    }
  };

  const getActivityLabel = (activityValue) => {

    const labels = {
      coding: "coding", // matches exactly
      running: "Running", // matches exactly (note capital R)
      "rock-climbing": "climbing", // maps to "climbing" in index
    };
    return labels[activityValue] || activityValue;
  };

  const performCombinedSearch = async (activityValue, priceFilter) => {
    return performActivitySearch(activityValue, priceFilter);
  };

  return {
    performSearch,
    performActivitySearch,
    performPriceOnlySearch,
    performCombinedSearch,
    fetchCompleteProductData,
    getActivityLabel,
  };
};

This code block glues together two back-end services: WP Engine Smart Search for all your full-text, semantic, and strict “find” queries (including category and range filters) and WPGraphQL for authoritative product details. 

Each of its methods (performSearch, performActivitySearch, performPriceOnlySearch, and performCombinedSearch) constructs a single GraphQL find call that tells Smart Search exactly how to filter (via its query, filter, semanticSearch, or strictMode inputs).

Smart Search returns only the IDs, scores, and minimal data you need; then fetchCompleteProductData issues one batched WPGraphQL request to pull down images, prices, and slugs, merging them back into your UI payload. 

Here’s a detailed breakdown:

  1. Initialization
    • It pulls in two low-level operations from useSmartSearch:
      • searchProducts(query, options) to run any “find” query against the Smart Search API.
      • getProductDetails(ids) to fetch full WPGraphQL product data (images, pricing) by database ID.
    • It also defines a reactive resultsLimit (default 20) to control page size.
  2. Mapping Basic Results
    • mapBasicResults takes the raw documents array returned by Smart Search—which each contains a data map and a relevance score—and converts it to a minimal product stub { id, title, description, score, image: "", price: 0 }.
  3. Text‐Query Search (performSearch)
    • Validates the input query, records start time, then calls searchProducts(query, { limit }).
    • Throws if the API response is malformed.
    • Builds basic stubs via mapBasicResults, then immediately calls fetchCompleteProductData to enrich each result with image URLs and numeric pricing.
    • Returns { success, results, total, searchTime, query }.
  4. Category/Activity Search (performActivitySearch)
    • Ensures a non-empty activity value, then issues a searchProducts request where the query is simply the exact category name (e.g. “Running”) and strictMode: true to disable semantic fuzziness.
    • Enriches with full product data, then optionally applies a client-side price filter if one was passed in.
  5. Price‐Only Search (performPriceOnlySearch)
    • Builds a “catch-all” query (“*” or scoped to a category) with strictMode: true.
    • Fetches the matching products, enriches them, then filters the enriched list on the client by the given { min, max } range.
  6. Data Enrichment (fetchCompleteProductData)
    • Given an array of basic stubs, batches a WPGraphQL call for all their IDs.
    • Maps the GraphQL response nodes back onto your stubs, filling in image, price, and formatting into formattedPrice, flagging missing/failed items.
  7. Utility and Labels
    • getActivityLabel maps your UI’s “activity” values (e.g., “rock-climbing”) to the exact category names in your Smart Search index.
    • A tiny wrapper, performCombinedSearch simply delegates to performActivitySearchso you can hook into a unified API.

Our Components

It is time to build all the components that will allow our e-commerce site to have a user experience with data rendered on the browser.  At the root of the project, create a folder called components.  We will be staying in the components folder for all of this section.

The Input Field

First, let’s make the Input field for our users to type into for searching.

Create a file at components/SearchInput.vue and paste in the code below:

<template>
  <div class="search-input">
    <!-- Search Input Container -->
    <div class="search-container mb-6">
      <div class="relative">
        <input
          v-model="searchQuery"
          @input="handleInput"
          @keyup.enter="handleSubmit"
          type="text"
          :placeholder="placeholder"
          class="w-full px-4 py-3 pl-12 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
        />

        <!-- Search Icon -->
        <div
          class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"
        >
          <SearchIcon />
        </div>

        <!-- Clear Button -->
        <button
          v-if="searchQuery"
          @click="handleClear"
          class="absolute inset-y-0 right-0 pr-3 flex items-center hover:text-gray-600"
        >
          <CloseIcon customClass="h-5 w-5 text-gray-400" />
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, watch } from "vue";
import SearchIcon from "~/components/icons/SearchIcon.vue";
import CloseIcon from "~/components/icons/CloseIcon.vue";

// Props
const props = defineProps({
  initialQuery: {
    type: String,
    default: "",
  },
  placeholder: {
    type: String,
    default: "Search products...",
  },
});

// Emits
const emit = defineEmits(["search", "clear", "input"]);

// Reactive data
const searchQuery = ref(props.initialQuery);

// Debounce timer
let searchTimeout = null;

// Methods
const handleInput = () => {
  clearTimeout(searchTimeout);
  searchTimeout = setTimeout(() => {
    emit("input", searchQuery.value);
  }, 300); // 300ms debounce
};

const handleSubmit = () => {
  emit("search", searchQuery.value);
};

const handleClear = () => {
  searchQuery.value = "";
  emit("clear");
};

// Watch for external changes to search query
watch(
  () => props.initialQuery,
  (newQuery) => {
    searchQuery.value = newQuery;
  }
);

// Expose methods for parent component
defineExpose({
  clearQuery: () => {
    searchQuery.value = "";
  },
  setQuery: (query) => {
    searchQuery.value = query;
  },
  searchQuery: searchQuery,
});
</script>

<style scoped>
.search-container {
  max-width: 800px;
  margin: 0 auto;
}
</style>

The SearchInput.vue component renders a styled text input with a built‑in search icon and “clear” button. It accepts two props—initialQuery to seed the field and placeholder for the hint text—and binds its value to a reactive searchQuery via v‑model

As the user types, it debounces input by 300 ms before emitting an “input” event, fires a “search” event on Enter, and shows a clear button that resets the field and emits “clear”. It also watches initialQuery for external changes and exposes clearQuery and setQuery methods so parent components can programmatically control the input.

Activity Filter

 Next, let’s make the filter to allow users to select an activity.  Create a file at components/ActivityFilter.vue  and paste in the code below:

<template>
  <div class="activity-filter mb-6">
    <div class="flex items-center justify-between mb-4">
      <h3 class="text-lg font-medium text-gray-900">Filter by Activity</h3>
      <button
        v-if="selectedActivity"
        @click="clearFilter"
        type="button"
        class="text-sm text-blue-600 hover:text-blue-800"
      >
        Clear Filter
      </button>
    </div>

    <div class="flex flex-wrap gap-3">
      <button
        v-for="activity in activities"
        :key="activity.value"
        type="button"
        @click="selectActivity(activity.value)"
        :aria-pressed="selectedActivity === activity.value"
        :class="[
          'px-4 py-2 rounded-full border text-sm font-medium transition-colors',
          selectedActivity === activity.value
            ? 'bg-blue-600 text-white border-blue-600'
            : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50',
        ]"
      >
        {{ activity.label }}
      </button>
    </div>

    <div
      v-if="selectedActivity"
      class="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200"
    >
      <div class="flex items-center justify-between">
        <span class="text-sm text-blue-800">
          <strong>Active Filter:</strong>
          {{ getActivityLabel(selectedActivity) }}
        </span>
        <button
          @click="clearFilter"
          type="button"
          class="text-blue-600 hover:text-blue-800"
          aria-label="Clear activity filter"
        >
          <CloseIcon />
        </button>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from "vue";
import CloseIcon from "~/components/icons/CloseIcon.vue";

const props = defineProps({
  initialActivity: {
    type: String,
    default: "",
  },
});

const emit = defineEmits(["activity-selected", "activity-cleared"]);

const selectedActivity = ref(props.initialActivity);

const activities = [
  { value: "coding", label: "Coding" },
  { value: "running", label: "Running" },
  { value: "rock-climbing", label: "Rock Climbing" },
];

function selectActivity(activity) {
  selectedActivity.value = activity;
  emit("activity-selected", activity);
}

function clearFilter() {
  selectedActivity.value = "";
  emit("activity-cleared");
}

function getActivityLabel(value) {
  const activity = activities.find((a) => a.value === value);
  return activity ? activity.label : value;
}

defineExpose({
  clearActivity: clearFilter,
  setActivity: selectActivity,
});
</script>

The ActivityFilter.vue component renders a set of pill‑style buttons—“Coding,” “Running,” and “Rock Climbing”—allowing users to select one activity at a time. It accepts an initialActivity prop to pre‑select a button and emits activity‑selected with the chosen value whenever a button is clicked. 

A “Clear Filter” button appears when a selection exists, resetting the state and emitting activity‑cleared. We use Vue’s ref for reactive state, simple methods to update and clear the selection, and defineExpose to let parent components programmatically set or clear the filter.

Price Range

Now, let’s give users the ability to slide a range within pricing.  Create a file at components/PriceFilter.vue and paste this code block in:

<template>
  <div class="price-filter mb-6">
    <div class="flex items-center justify-between mb-4">
      <h3 class="text-lg font-medium text-gray-900">Filter by Price</h3>
      <button
        v-if="priceRange.min > 0 || priceRange.max < maxPrice"
        @click="clearFilter"
        type="button"
        class="text-sm text-blue-600 hover:text-blue-800"
      >
        Reset Price
      </button>
    </div>

    <div class="px-3">
      <div class="flex justify-between items-center mb-4">
        <span class="text-sm font-medium text-gray-700"
          >${{ priceRange.min }}</span
        >
        <span class="text-sm text-gray-500">to</span>
        <span class="text-sm font-medium text-gray-700"
          >${{ priceRange.max }}</span
        >
      </div>

      <div class="relative">
        <div class="h-2 bg-gray-200 rounded-lg relative">
          <div
            class="absolute h-2 bg-blue-500 rounded-lg"
            :style="{ left: percentLeft, width: percentWidth }"
          />
        </div>

        <input
          v-model.number="priceRange.min"
          @input="handlePriceChange"
          type="range"
          :min="0"
          :max="maxPrice"
          :step="10"
          aria-label="Minimum price"
          class="absolute w-full h-2 bg-transparent appearance-none cursor-pointer slider-thumb"
        />

        <input
          v-model.number="priceRange.max"
          @input="handlePriceChange"
          type="range"
          :min="0"
          :max="maxPrice"
          :step="10"
          aria-label="Maximum price"
          class="absolute w-full h-2 bg-transparent appearance-none cursor-pointer slider-thumb"
        />
      </div>

      <button
        v-if="priceRange.min > 0 || priceRange.max < maxPrice"
        @click="applyFilter"
        type="button"
        class="w-full mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
      >
        Apply Price Filter (${{ priceRange.min }} - ${{ priceRange.max }})
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, toRef, onUnmounted } from "vue";

const props = defineProps({
  initialMin: { type: Number, default: 0 },
  initialMax: { type: Number, default: 1000 },
  maxPrice: { type: Number, default: 1000 },
});
const emit = defineEmits(["price-changed", "price-applied", "price-cleared"]);

const priceRange = ref({ min: props.initialMin, max: props.initialMax });
const maxPrice = toRef(props, "maxPrice");

let priceTimeout;

const percentLeft = computed(
  () => `${(priceRange.value.min / maxPrice.value) * 100}%`
);
const percentWidth = computed(
  () =>
    `${((priceRange.value.max - priceRange.value.min) / maxPrice.value) * 100}%`
);

function handlePriceChange() {
  let { min, max } = priceRange.value;
  if (min > max) {
    min = max;
  } else if (max < min) {
    max = min;
  }
  priceRange.value.min = min;
  priceRange.value.max = max;

  emit("price-changed", { min, max });
  clearTimeout(priceTimeout);
  priceTimeout = setTimeout(() => {
    if (priceRange.value.min > 0 || priceRange.value.max < maxPrice.value) {
      emit("price-applied", {
        min: priceRange.value.min,
        max: priceRange.value.max,
      });
    }
  }, 1000);
}

function clearFilter() {
  priceRange.value.min = 0;
  priceRange.value.max = maxPrice.value;
  emit("price-cleared");
}

function applyFilter() {
  emit("price-applied", {
    min: priceRange.value.min,
    max: priceRange.value.max,
  });
}

onUnmounted(() => {
  clearTimeout(priceTimeout);
});

defineExpose({ clearPrice: clearFilter });
</script>

<style scoped>
.slider-thumb::-webkit-slider-thumb,
.slider-thumb::-moz-range-thumb {
  appearance: none;
  height: 20px;
  width: 20px;
  border-radius: 50%;
  background: #3b82f6;
  cursor: pointer;
  border: 2px solid #ffffff;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);

  position: relative;
  z-index: 1;
}
.slider-thumb:hover::-webkit-slider-thumb,
.slider-thumb:hover::-moz-range-thumb {
  background: #2563eb;
}
.slider-thumb:active::-webkit-slider-thumb,
.slider-thumb:active::-moz-range-thumb {
  background: #1d4ed8;
}
</style>

The PriceFilter.vue component provides a dual‑thumb price slider with live updates, preset buttons, and clear/apply controls. It accepts initialMin, initialMax, and maxPrice props to initialize its reactive priceRange and dynamically computes the filled‑track positions (percentLeft and percentWidth). 

As the user drags either thumb, handlePriceChange clamps the values so min ≤ max, emits a price‑changed event immediately, then debounces a price‑applied event by 1 second of inactivity. Clicking a preset button jumps to that range and fires price‑applied at once, while the “Reset Price” and “Apply Price Filter” buttons emit price‑cleared and price‑applied. 

It cleans up its debounce timer on unmount and exposes clearPrice and setPrice methods so parent components can programmatically reset or set the range. 

Search Results Display

The next thing we need to do is display the search results.  Create a file at components/SearchResults.vue and paste this code block in:

<template>
  <div class="search-results">
    <!-- Loading State -->
    <div v-if="isLoading" class="text-center py-6">
      <div class="inline-flex items-center">
        <LoadingSpinner />
        <span class="text-lg">Searching...</span>
      </div>
    </div>

    <!-- Search Results -->
    <div v-else-if="results.length > 0" class="search-results-content">
      <!-- Results Header -->
      <div class="mb-6 flex justify-between items-center">
        <div class="text-lg font-medium text-gray-700">
          Found {{ totalResults }} products
          <span v-if="searchTime" class="text-sm text-gray-500"
            >({{ searchTime }}ms)</span
          >
        </div>
        <button
          @click="clearResults"
          class="text-sm text-gray-600 hover:text-gray-800"
        >
          Clear Results
        </button>
      </div>

      <!-- Products Grid -->
      <div
        class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
      >
        <div v-for="product in results" :key="product.id">
          <ProductCard :product="product" />
        </div>
      </div>
    </div>

    <!-- No Results -->
    <div v-else-if="hasSearched && !isLoading" class="text-center py-12">
      <div class="text-gray-500">
        <NoResultsIcon />
        <h3 class="text-xl font-medium text-gray-900 mb-2">
          No products found
        </h3>
        <p class="text-gray-600">
          Try adjusting your search terms or search options
        </p>
      </div>
    </div>

    <!-- Error State -->
    <div
      v-if="error"
      class="bg-red-50 border border-red-200 rounded-lg p-4 mb-6"
    >
      <div class="flex">
        <ErrorIcon />
        <div class="ml-3">
          <h3 class="text-sm font-medium text-red-800">Search Error</h3>
          <p class="text-sm text-red-700 mt-1">{{ error }}</p>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import ProductCard from "~/components/ProductCard.vue";
import LoadingSpinner from "~/components/icons/LoadingSpinner.vue";
import NoResultsIcon from "~/components/icons/NoResultsIcon.vue";
import ErrorIcon from "~/components/icons/ErrorIcon.vue";

// Props
const props = defineProps({
  results: {
    type: Array,
    default: () => [],
  },
  totalResults: {
    type: Number,
    default: 0,
  },
  isLoading: {
    type: Boolean,
    default: false,
  },
  hasSearched: {
    type: Boolean,
    default: false,
  },
  error: {
    type: String,
    default: "",
  },
  searchTime: {
    type: Number,
    default: 0,
  },
});

// Emits
const emit = defineEmits(["clear-results"]);

// Methods
const clearResults = () => {
  emit("clear-results");
};
</script>

<style scoped>
.search-results-content {
  animation: fadeIn 0.3s ease-out;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
</style>

The SearchResults.vue component handles all the display states for your product search: it shows a spinning loader when isLoading is true; once results arrive (results.length > 0), it renders a header with the total count and search time alongside a “Clear Results” button, then lays out each product via a <ProductCard> grid; if the search has been performed but yielded no hits, it displays a  “No products found” message; and if an error string is present, it surfaces a styled error banner with the message. 

By accepting props for results, totalResults, isLoading, hasSearched, error, and searchTime, and emitting a single clear-results event, it holds all the UI you need to reflect loading, success, empty, and error conditions.

The next thing we need to make is the search bar, which will bring in the previous four components we just built.  Create a file at components/SearchBar.vue and paste this code block in:

<template>
  <div class="search-bar">
    <!-- Search Input Component -->
    <SearchInput
      ref="searchInputRef"
      :initial-query="initialQuery"
      :placeholder="placeholder"
      @input="handleSearchInput"
      @search="handleSearchSubmit"
      @clear="handleSearchClear"
    />

    <!-- Activity Filter Component -->
    <ActivityFilter
      ref="activityFilterRef"
      :initial-activity="selectedActivity"
      @activity-selected="handleActivitySelected"
      @activity-cleared="handleActivityCleared"
    />

    <!-- Price Filter Component -->
    <PriceFilter
      ref="priceFilterRef"
      :initial-min="priceRange.min"
      :initial-max="priceRange.max"
      :max-price="maxPrice"
      @price-changed="handlePriceChanged"
      @price-applied="handlePriceApplied"
      @price-cleared="handlePriceCleared"
    />

    <!-- Search Results Component -->
    <SearchResults
      :results="searchResults"
      :total-results="totalResults"
      :is-loading="isLoading"
      :has-searched="hasSearched"
      :error="error"
      :search-time="searchTime"
      @clear-results="handleClearResults"
    />
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import SearchInput from "./SearchInput.vue";
import ActivityFilter from "./ActivityFilter.vue";
import PriceFilter from "./PriceFilter.vue";
import SearchResults from "./SearchResults.vue";
import { useSearchLogic } from "~/composables/useSearchLogic";

// Props
const props = defineProps({
  initialQuery: {
    type: String,
    default: "",
  },
  placeholder: {
    type: String,
    default: "Search products...",
  },
});

// Emits
const emit = defineEmits(["search-results", "search-start", "search-complete"]);

// Use search logic composable
const {
  performSearch,
  performActivitySearch,
  performPriceOnlySearch,
  performCombinedSearch,
} = useSearchLogic();

// Component refs
const searchInputRef = ref(null);
const activityFilterRef = ref(null);
const priceFilterRef = ref(null);

// Reactive data
const searchResults = ref([]);
const totalResults = ref(0);
const isLoading = ref(false);
const hasSearched = ref(false);
const error = ref("");
const searchTime = ref(0);

// Filter states
const selectedActivity = ref("");
const priceRange = ref({
  min: 0,
  max: 1000,
});
const maxPrice = ref(1000);

// Search Input Event Handlers
const handleSearchInput = async (query) => {
  if (query.trim()) {
    await executeSearch(query, "semantic-search");
  } else {
    clearResults();
  }
};

const handleSearchSubmit = async (query) => {
  if (query.trim()) {
    await executeSearch(query, "semantic-search");
  }
};

const handleSearchClear = () => {
  clearResults();
  clearAllFilters();
};

// Activity Filter Event Handlers
const handleActivitySelected = async (activityValue) => {
  selectedActivity.value = activityValue;
  searchInputRef.value?.clearQuery();

  // Check if price filter is active
  const hasPriceFilter =
    priceRange.value.min > 0 || priceRange.value.max < maxPrice.value;

  if (hasPriceFilter) {
    // Use combined search for activity + price
    await executeCombinedSearch(activityValue, priceRange.value);
  } else {
    // Use activity-only search
    await executeActivitySearch(activityValue);
  }
};

const handleActivityCleared = () => {
  selectedActivity.value = "";
  clearResults();
};

// Price Filter Event Handlers
const handlePriceChanged = (priceData) => {
  priceRange.value = priceData;
};

const handlePriceApplied = async (priceData) => {
  priceRange.value = priceData;

  // Check if activity filter is active
  if (selectedActivity.value) {
    // Use combined search for activity + price
    await executeCombinedSearch(selectedActivity.value, priceData);
  } else {
    // Use price-only search
    await executePriceFilter();
  }
};

const handlePriceCleared = () => {
  priceRange.value = { min: 0, max: maxPrice.value };
  // Re-run current search without price filter
  if (searchInputRef.value?.searchQuery?.trim()) {
    executeSearch(searchInputRef.value.searchQuery, "semantic-search");
  } else if (selectedActivity.value) {
    // Re-run activity search without price filter
    executeActivitySearch(selectedActivity.value);
  }
};

// Results Event Handlers
const handleClearResults = () => {
  clearResults();
  clearAllFilters();
};

// Core Search Execution Methods
const executeSearch = async (query, type) => {
  isLoading.value = true;
  error.value = "";
  hasSearched.value = true;

  emit("search-start", { query, type });

  const result = await performSearch(query);

  if (result.success) {
    searchResults.value = result.results;
    totalResults.value = result.total;
    searchTime.value = result.searchTime;

    emit("search-results", {
      results: searchResults.value,
      total: totalResults.value,
      query,
      type,
      time: searchTime.value,
    });
  } else {
    error.value = result.error;
    searchResults.value = [];
    totalResults.value = 0;
  }

  isLoading.value = false;
  emit("search-complete", {
    success: result.success,
    resultsCount: searchResults.value.length,
  });
};

const executeActivitySearch = async (activityValue) => {
  isLoading.value = true;
  error.value = "";
  hasSearched.value = true;

  emit("search-start", { query: activityValue, type: "activity-filter" });

  const result = await performActivitySearch(activityValue);

  if (result.success) {
    searchResults.value = result.results;
    totalResults.value = result.total;
    searchTime.value = result.searchTime;

    emit("search-results", {
      results: searchResults.value,
      total: totalResults.value,
      query: result.query,
      type: "activity-filter",
      time: searchTime.value,
    });
  } else {
    error.value = result.error;
    searchResults.value = [];
    totalResults.value = 0;
  }

  isLoading.value = false;
  emit("search-complete", {
    success: result.success,
    resultsCount: searchResults.value.length,
  });
};

const executePriceFilter = async () => {
  await executePriceOnlySearch();
};

const executePriceOnlySearch = async () => {
  isLoading.value = true;
  error.value = "";
  hasSearched.value = true;

  const query = `Price: $${priceRange.value.min} - $${priceRange.value.max}`;
  emit("search-start", { query, type: "price-filter" });

  // Pass activity filter if active
  const result = await performPriceOnlySearch(
    priceRange.value,
    selectedActivity.value || null
  );

  if (result.success) {
    searchResults.value = result.results;
    totalResults.value = result.total;
    searchTime.value = result.searchTime;

    emit("search-results", {
      results: searchResults.value,
      total: totalResults.value,
      query: result.query,
      type: selectedActivity.value ? "combined-filter" : "price-filter",
      time: searchTime.value,
    });
  } else {
    error.value = result.error;
    searchResults.value = [];
    totalResults.value = 0;
  }

  isLoading.value = false;
  emit("search-complete", {
    success: result.success,
    resultsCount: searchResults.value.length,
  });
};

const executeCombinedSearch = async (activityValue, priceData) => {
  isLoading.value = true;
  error.value = "";
  hasSearched.value = true;

  const query = `Activity: ${activityValue} | Price: $${priceData.min} - $${priceData.max}`;
  emit("search-start", { query, type: "combined-filter" });

  const result = await performCombinedSearch(activityValue, priceData);

  if (result.success) {
    searchResults.value = result.results;
    totalResults.value = result.total;
    searchTime.value = result.searchTime;

    emit("search-results", {
      results: searchResults.value,
      total: totalResults.value,
      query: result.query,
      type: "combined-filter",
      time: searchTime.value,
    });
  } else {
    error.value = result.error;
    searchResults.value = [];
    totalResults.value = 0;
  }

  isLoading.value = false;
  emit("search-complete", {
    success: result.success,
    resultsCount: searchResults.value.length,
  });
};

// Utility Methods
const clearResults = () => {
  searchResults.value = [];
  totalResults.value = 0;
  hasSearched.value = false;
  error.value = "";
  searchTime.value = 0;
};

const clearAllFilters = () => {
  selectedActivity.value = "";
  priceRange.value = { min: 0, max: maxPrice.value };
  searchInputRef.value?.clearQuery();
  activityFilterRef.value?.clearActivity();
  priceFilterRef.value?.clearPrice();
};

// Handle clear search from home link
const handleClearFromHome = () => {
  clearResults();
  clearAllFilters();
  // Emit empty search results to reset the parent state
  emit("search-results", {
    results: [],
    total: 0,
    query: "",
    type: "clear",
    time: 0,
  });
};

// Listen for clear search event from home link
onMounted(() => {
  if (process.client) {
    window.addEventListener("clear-search-from-home", handleClearFromHome);
  }
});

onUnmounted(() => {
  if (process.client) {
    window.removeEventListener("clear-search-from-home", handleClearFromHome);
  }
});
</script>

<style scoped>
/* Main container styles */
</style>

This is our top‑level orchestration component that stitches together four child pieces—SearchInput, ActivityFilter, PriceFilter, and SearchResults—with the shared useSearchLogic composable. 

It maintains reactive state for query text, selected activity, price range, loading status, results, errors, and timing; wires each child’s events into handlers that call performSearch, performActivitySearch, or executePriceFilter; and emits high‑level lifecycle events (search-start, search-results, search-complete) for parent components. 

It also listens for a global clear-search-from-home browser event to reset all filters and results, ensuring the entire search UI can be programmatically cleared from elsewhere in the app.

Add the Search Bar To All Routes

The next step is to make our search bar accessible on all product routes. To do that, we can add it to the product layout.
Navigate to the layouts/products.vue file and paste this code in:

<template>
  <div>
    <header class="shadow-sm bg-white">
      <nav class="container mx-auto p-4">
        <NuxtLink to="/" class="font-bold">Nuxt Headless WP Demo</NuxtLink>
      </nav>
    </header>

    <!-- Search Bar Section -->
    <div class="bg-gray-50 border-b">
      <div class="container mx-auto p-4">
        <SearchBar @search-results="handleSearchResults" />
      </div>
    </div>

    <div class="container mx-auto p-4">
      <slot />
    </div>
    <footer class="container mx-auto p-4 flex justify-between border-t-2">
      <ul class="flex gap-4"></ul>
    </footer>
  </div>
</template>

<script setup>
import SearchBar from "~/components/SearchBar.vue";

// Handle search results from SearchBar and emit to pages
const handleSearchResults = (searchData) => {
  // Dispatch custom event that pages can listen to
  if (process.client) {
    const event = new CustomEvent("layout-search-results", {
      detail: searchData,
    });
    window.dispatchEvent(event);
  }
};
</script>

<style scoped>
.router-link-exact-active {
  color: #12b488;
}
</style>

This updated layout file allows us to have our Search Bar on all product routes.

SVG Icons

Next, let’s make seven icon components to extract the inline SVG elements into separate Vue components to have good code readability and keep it neat.

All seven components are simple, reusable Vue components that render styled SVG icons for the different icons we need. Create a icons folder in the components directory. In that icons folder, create the files below. I linked each component to the code block you need to copy and paste into that file in my final GitHub repo for this article. Go ahead and click each file to get the code you need to paste into your own project.

Note: Update the pages/index.vue component with this code here. This imports the SVG components that the index page needs as well as handling the state.

Update The Index Page To Handle State

Lastly, let’s update our index.vue file so that the index page can handle search state.  Go to pages/index.vue and paste this code in:

<template>
  <div>
    <!-- Loading State -->
    <div v-if="pending" class="text-center py-12">
      <div class="inline-flex items-center">
        <LoadingSpinner
          customClass="animate-spin -ml-1 mr-3 h-8 w-8 text-blue-500"
        />
        <span class="text-lg">Loading products...</span>
      </div>
    </div>

    <!-- Error State -->
    <div v-else-if="error" class="text-center py-12">
      <div class="text-red-600">
        <ErrorIcon customClass="mx-auto h-16 w-16 text-red-400 mb-4" />
        <h3 class="text-xl font-medium text-gray-900 mb-2">
          Failed to load products
        </h3>
        <p class="text-gray-600 mb-4">
          {{ error.message || "Please try again later" }}
        </p>
        <button @click="refresh()" class="btn">Try Again</button>
      </div>
    </div>

    <!-- Default Products (shown when no search active) -->
    <div v-else-if="!searchActive && products?.length" class="default-products">
      <h2 class="text-2xl font-bold mb-6">All Products</h2>
      <div class="grid grid-cols-4 gap-5">
        <div v-for="p in products" :key="p.id">
          <ProductCard :product="p" />
        </div>
      </div>
    </div>

    <!-- No Products State -->
    <div
      v-else-if="!searchActive && !products?.length"
      class="text-center py-12"
    >
      <div class="text-gray-500">
        <EmptyBoxIcon />
        <h3 class="text-xl font-medium text-gray-900 mb-2">
          No products available
        </h3>
        <p class="text-gray-600">Check back later for new products</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import ProductCard from "~/components/ProductCard.vue";
import LoadingSpinner from "~/components/icons/LoadingSpinner.vue";
import ErrorIcon from "~/components/icons/ErrorIcon.vue";
import EmptyBoxIcon from "~/components/icons/EmptyBoxIcon.vue";

// Search state
const searchActive = ref(false);

// Handle search results from layout SearchBar
const handleSearchResults = (event) => {
  const searchData = event.detail;
  searchActive.value = searchData.results.length > 0 || searchData.query.trim();
};

// Handle home link click to reset search
const handleResetSearch = () => {
  searchActive.value = false;
  // Also clear the search in the SearchBar component
  const searchBarEvent = new CustomEvent("clear-search-from-home");
  window.dispatchEvent(searchBarEvent);
};

// Listen for search results from layout and reset search event
onMounted(() => {
  if (process.client) {
    window.addEventListener("layout-search-results", handleSearchResults);
    window.addEventListener("reset-search", handleResetSearch);
  }
});

onUnmounted(() => {
  if (process.client) {
    window.removeEventListener("layout-search-results", handleSearchResults);
    window.removeEventListener("reset-search", handleResetSearch);
  }
});

// Fetch the products from WooCommerce via GraphQL
const {
  data: products,
  pending,
  error,
  refresh,
} = await useFetch(useRuntimeConfig().public.wordpressUrl, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: {
    query: `
      query GetProducts($first: Int = 10) {
        products(first: $first) {
          edges {
            node {
              databaseId
              name
              image {
                sourceUrl
                altText
              }
            }
          }
        }
      }
    `,
    variables: {
      first: 10,
    },
  },
  transform: (data) => {
    return data.data.products.edges.map((edge) => ({
      id: edge.node.databaseId,
      title: edge.node.name,
      image: edge.node.image?.sourceUrl || "/placeholder.jpg",
    }));
  },
  key: "products-list",
});

definePageMeta({
  layout: "products",
});

useHead({
  title: "Nuxt headlesswp eCommerce | All Products",
  meta: [
    {
      name: "description",
      content:
        "Browse our complete collection of products in our headless WordPress eCommerce store",
    },
  ],
});
</script>

Here is what we added to the index.vue file and what it does:

searchActive ref: Tracks whether a search is in effect so you can suppress the default “All Products” grid when search results exist.

Event handlers (handleSearchResults, handleResetSearch): Listen for custom events emitted by the shared SearchBar in the layout, updating searchActive (and clearing the bar) when searches start or are reset.

Lifecycle hooks: Hook into onMounted/onUnmounted to register and clean up those global event listeners.

pending, error, refresh from useFetch: Expose loading/error UI states and a manual retry button.

Expanded template logic: Four mutually exclusive branches to render “loading,” “error,” “default products,” or “no products” based on fetch status and search activity.

We are now ready to test this in the browser.  Run npm run dev in your terminal.  When visiting http://localhost:3000, you should now see a search bar and filters on your home page. Test the search and try the filters.  This is the experience you should get:

Search STOKE!!! 

Here is the final repo for reference:

https://github.com/Fran-A-Dev/smart-search-headlesswp-ecomm

Conclusion

We hope this article helped you understand how to create a filtered product experience in Nuxt.js with WP Engine Smart Search AI. By surfacing relevant products faster—with semantic, activity, and price-aware filtering—you give customers the ability to zero in on what they want, spend less time searching, and thus have a seamless purchasing experience. 

If you’re building headless commerce, this kind of search-driven discovery can stoke engagement and revenue. We’d love to hear what you build next—drop into the Headless WordPress Discord and share your projects or feedback.  Happy Coding!

* WP Engine is a proud member and supporter of the community of WordPress® users. The WordPress® trademarks are the intellectual property of the WordPress Foundation, and the Woo® and WooCommerce® trademarks are the intellectual property of WooCommerce, Inc. Uses of the WordPress®, Woo®, and WooCommerce® names in this website are for identification purposes only and do not imply an endorsement by WordPress Foundation or WooCommerce, Inc. WP Engine is not endorsed or owned by, or affiliated with, the WordPress Foundation or WooCommerce, Inc.

The post How to Create a Headless E-Commerce Search Experience With WP Engine’s Smart Search AI and Nuxt.js appeared first on Builders.

]]>
https://wpengine.com/builders/how-to-create-a-headless-e-commerce-search-experience-with-wp-engines-smart-search-ai-and-nuxt-js/feed/ 0
Next.js + WordPress: Routing and GraphQL https://wpengine.com/builders/next-js-wordpress-routing-and-graphql/ https://wpengine.com/builders/next-js-wordpress-routing-and-graphql/#respond Mon, 04 Aug 2025 22:33:27 +0000 https://wpengine.com/builders/?p=31946 Next.js is one of the most popular front-ends for building with headless WordPress. My Reddit notifications are littered with Next.js + headless WordPress recommendations. Today, we’re going to look at […]

The post Next.js + WordPress: Routing and GraphQL appeared first on Builders.

]]>
Next.js is one of the most popular front-ends for building with headless WordPress. My Reddit notifications are littered with Next.js + headless WordPress recommendations. Today, we’re going to look at implementing routing and data fetching for headless WordPress with Next.js.

You may wonder why we’re covering this, since WP Engine is behind Faust.js, which provides its own routing solution for headless WordPress + Next.js sites. Faust’s routing solution isn’t perfect, however. In this article, we’ll experiment with another approach that offers improvements. We’ll be working with the Pages Router, though many of the concepts could be translated to the App Router.

The two major issues we’ll be looking at today are bundle splitting and query optimization. Currently, the catch-all route doesn’t bundle template code separately. While this might only cause a couple of KB of bloat on small sites, the more complexity you add means you might be loading 10-100 KBs of extra code on every route. 

When Faust was first conceived years ago, I don’t think the team fully understood the importance of small queries. Because of this, Faust’s main mechanism for querying GraphQL only allows for one query per template. We have since learned that this is an antipattern. Just because you can query everything you need from GraphQL in one request doesn’t mean you should. In this post, we’ll also experiment with alternative ways to handle data fetching. 

For a working example of what we discuss here, check out the wpengine/hwptoolkit repo.

Note: We recently announced that we’re working on improving Faust. The work I did for this article and much more is going into improving Faust.

Routing

In the article on Astro, we discussed four major steps in the template hierarchy that must be recreated for a front-end framework. URI => Data => Template => Render: Data + Template.

In our article on SvelteKit, we experimented with new routing methods due to its implementation details. Next is similar in that middleware and rewrites just won’t work for us.  However, unlike SvelteKit, Next doesn’t have a way to load components outside of components. 

Next.js does have the ability to dynamically import components, which will solve our bundling issue. Our template loader will only dynamically import the needed template, not all templates.

Template Hierarchy in Next.js

Let’s put this all together in Next.js. The steps are:

  1. Get the URI
  2. Determine the template
    • Make a “seed query” to WordPress to fetch template data
    • Collect available templates for rendering
    • Calculate possible templates the data could use
    • Figure out which of the available templates to use based on the prioritized order of most to least specific possible templates
    • Use the dynamically imported template
  3. Fetch more data from WordPress to actually render the template
  4. Merge the selected template and data for rendering

Catch-All Route

To get the full URI, we’ll use Next’s file-system router and optional catch-all route: src/pages/[[...uri]].js

Note: The [...uri].js pattern may be more common, but  it requires a value for uri. This means root (/) routes aren’t included. This is commonly not understood, and folks also include an index.js to handle this usecase. However, the double brackets make uri optional and thus inclusive of /. This undefined value will need to be handled later.

Seed Query

In the Next Pages Router, all server-side queries will need to be executed in getStaticProps or, more commonly, getServerSideProps; either way, this will be in the src/pages/[[...uri]].js route.

Calculating Possible Templates

Our app will use a function we built for Faust to take the data from the seed query and generate a list of possible templates, sorted from most specific to least specific. For example, the templates for a page could look like this: [page-sample-page, page-2, page, singular, index].

Creating Available Templates

Because we’re using dynamic imports to import our WordPress templates, they don’t have to be dedicated routes. However, we do need a single location where they all exist so we can easily import them programmatically. We will use a wp-templates directory with our templates inside, like this:

src

src
  ↳ wp-templates/
    ↳ index.js
    ↳ default.js
    ↳ home.js
    ↳ archive.js
    ↳ single.js

With Astro and SvelteKit, I opted to read these from the file system to avoid having to import individual templates manually. Unfortunately, Next won’t allow us to do this. Because of the limitations in Next’s bundler and how next/dynamic Works, they make it clear in the documentation that variables can’t be used; static strings are required!

This means we use index.js in our wp-templates folder to handle dynamically importing the individual templates and exporting them into key-value pairs, where the keys are the expected WP template names. In our example above, this is mostly 1-to-1, though default.js will become index.

Choosing a template

We now have a list of possible templates and a list of available templates. Based on the prioritized list of possible templates, we can determine which of the available templates to use. 

A quick bit of JavaScript can compare the list of possible templates [single-post-sample-post, single-post, single, singular, index] to the list of available templates [archive, home, archive, single] and the first match is our template. In this case, single is the winner!

Putting it all together

Now that we’ve built all the pieces, we can make a single function that takes a URI and returns the template. The getServerSideProps function of our catch-all route now looks something like this:

// src/pages/[[...uri]].js
import { uriToTemplate } from "@/lib/templateHierarchy";

export async function getServerSideProps(context) {
  const { params } = context;

  const uri = Array.isArray(params.uri)
    ? "/" + params.uri.join("/") + "/"
    : "/";

  const templateData = await uriToTemplate({ uri });

  if (
    !templateData?.template?.id ||
    templateData?.template?.id === "404 Not Found"
  ) {
    return {
      notFound: true,
    };
  }

  return {
    props: {
      uri,
      // https://github.com/vercel/next.js/discussions/11209#discussioncomment-35915
      templateData: JSON.parse(JSON.stringify(templateData)),
    },
  };
}

Loading the Template

Loading templates is done manually in the wp-templates/index.js. That will look something like:

// src/wp-templates/index.js

import dynamic from "next/dynamic";

const home = dynamic(() => import("./home.js"), {
  loading: () => <p>Loading Home Template...</p>,
});

const index = dynamic(() => import("./default.js"), {
  loading: () => <p>Loading Index Template...</p>,
});

const single = dynamic(() => import("./single.js"), {
  loading: () => <p>Loading Single Template...</p>,
});

export default { home, index, single };

Rendering the template

Okay! Our getServerSideProps function does the hard work of figuring out which template to render and loading the seed query. Now, in our page component, we can handle rendering the template. 

// src/pages/[[...uri]].js
import availableTemplates from "@/wp-templates";

export default function Page(props) {
  const { templateData } = props;

  const PageTemplate = availableTemplates[templateData.template?.id];

  return (
    <PageTemplate {...props} />
  );
}

Querying Data

Now that we have a working router, let’s turn to fetching data for our templates. Currently, Faust’s main mechanism is query and variables exports from a given template. These are handled upstream in the catch-all routes get____Props function. 

As mentioned previously, we want to improve this by allowing multiple queries per template. Faust started to implement this by allowing a queries export. Without getting into too many details, this implementation has its own set of problems. We were able to implement this same pattern in the SvelteKit example without much difficulty and avoided many of the issues. Let’s do the same here.

Defining Queries

While a full implementation might need some more advanced features, we’re going to keep ours fairly simple to start. 

Component.queries = [
  {
    name: myQuery,
    query: gql`
      //...
    `,
    variables: (_context, { uri }) => ({ uri })
  }
]

Instead of relying on complex hash algorithms to identify our queries, we’re going to use simple names. The GraphQL query name is used as a fallback if one is not provided. However, if you’re running one query with different variables, you may need to give it a unique name, so we provide the name field.

Executing Queries

In our getServerSideProps function, we’re already handling the loading of our template. Now, we can access this queries array from there and execute our queries. Initially, I thought this would look something like: 

const PageTemplate = availableTemplates[templateData.template?.id];

//Queries would then be available at
PageTemplate.queries

This didn’t work. Some console logs quickly made sense of the issue: 

{
  PageTemplate: {
    '$$typeof': Symbol(react.forward_ref),
    render: [Function: LoadableComponent] {
      preload: [Function (anonymous)],
      displayName: 'LoadableComponent'
    }
  },
}

What’s actually being loaded is the wrapper component from next/dynamic, not the component itself. Thus, it doesn’t have the queries value I added. But since this is an async component, I suspected I should be able to access queries if I load the component itself via the preload function.

const component = await PageTemplate.render.preload();

Sure enough, this worked:

const component = await PageTemplate.render.preload();

// Queries available at:
component.default.queries

Now that we have loaded our module and have access to queries, our array of queries will be handed off to a purpose-built function that can handle executing all the queries with their given config and variables, returning them in the expected structure. All together this will look something like:

// src/pages/[[...uri]].js
import { uriToTemplate } from "@/lib/templateHierarchy";
import availableTemplates from "@/wp-templates";
import { fetchQueries } from "@/lib/queryHandler";

export async function getServerSideProps(context) {
  const { params } = context;

  const uri = Array.isArray(params.uri)
    ? "/" + params.uri?.join("/") + "/"
    : "/";

  const templateData = await uriToTemplate({ uri });

  if (
    !templateData?.template?.id ||
    templateData?.template?.id === "404 Not Found"
  ) {
    return {
      notFound: true,
    };
  }

  const PageTemplate = availableTemplates[templateData.template?.id];

  const component = await PageTemplate.render.preload();

  const graphqlData = await fetchQueries({
    queries: component.default.queries,
    context,
    props: {
      uri,
      templateData,
    },
  });

  return {
    props: {
      uri,
      // https://github.com/vercel/next.js/discussions/11209#discussioncomment-35915
      templateData: JSON.parse(JSON.stringify(templateData)),
      graphqlData: JSON.parse(JSON.stringify(graphqlData)),
    },
  };
}

Component Queries

I like to be able co-locate my queries with the components they go with. So, leveraging the existing queries system I can similarly export query from individual components. For a navigation menu I could opt to pass the desired menu location in from the template to determine which menu is fetched and rendered.

In this example, I kept it simple and rendered a “Recent Posts” component on the home page.

import { gql } from "urql";
import { useRouteData } from "@/lib/context";

export default function RecentPosts() {
  const { graphqlData } = useRouteData();

  const posts = graphqlData?.RecentPosts?.data?.posts?.nodes || [];

  if (graphqlData?.RecentPosts?.error) {
    console.error("Error fetching RecentPosts:", graphqlData.RecentPosts.error);
    return <div>Error loading recent posts.</div>;
  }

  return (
    <div className="recent-posts">
      <h2>Recent Posts</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={post.uri}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

RecentPosts.query = {
  query: gql`
    query RecentPosts {
      posts(first: 5) {
        nodes {
          id
          title
          uri
        }
      }
    }
  `,
};

You may have noticed I used custom context to fetch the data. While I could pass this via props fairly easily, that’s not always the case. To avoid prop drilling, I added a context provider to our catch-all route to make page props available to all components.

export default function Page(props) {
  const { templateData } = props;

  const PageTemplate = availableTemplates[templateData.template?.id];

  return (
    <RouteDataProvider value={props}>
      <PageTemplate {...props} />
    </RouteDataProvider>
  );
}

Wrapping up

Just like that, we’ve managed to implement a template-hierarchy router and GraphQL data fetching for our templates. All the while, we have avoided some performance issues by enabling dynamic imports for templates and multiple query support for data fetching. 

This implementation is far from production-ready. I can think of a number of things the GraphQL data fetching doesn’t handle yet. But this shows us that with a little problem-solving, we can build some great solutions.

That said, between Astro, SvelteKit, and Next.js. Next has proven to be the most complicated implementation. The non-standard next/dynamic means extra steps for queries and manual registration of our wp-templates.

This comes down to strong async support in Astro and SvelteKit, while React has long struggled with supporting async data. Admittedly, Next App Router would likely help us simplify implementation complexities. But that’s a story for another day.

While my relationship with React/Next is tenuous at best, and I strongly prefer anything but, I still make a living maintaining sites using these technologies, and I learned a bunch about using them with headless WordPress. What do you think?


The post Next.js + WordPress: Routing and GraphQL appeared first on Builders.

]]>
https://wpengine.com/builders/next-js-wordpress-routing-and-graphql/feed/ 0
Create a Headless WordPress chatbot with WP Engine’s AI Toolkit, RAG, and Google Gemini https://wpengine.com/builders/create-a-headless-wordpress-chatbot-with-wp-engines-ai-toolkit-rag-and-google-gemini/ https://wpengine.com/builders/create-a-headless-wordpress-chatbot-with-wp-engines-ai-toolkit-rag-and-google-gemini/#respond Fri, 27 Jun 2025 17:53:07 +0000 https://wpengine.com/builders/?p=31923 In this step-by-step guide, we will build a full-stack application that uses WP Engine’s AI Toolkit, Retrieval Augmented Generation (RAG), and Google Gemini to deliver accurate and contextually relevant responses […]

The post Create a Headless WordPress chatbot with WP Engine’s AI Toolkit, RAG, and Google Gemini appeared first on Builders.

]]>
In this step-by-step guide, we will build a full-stack application that uses WP Engine’s AI Toolkit, Retrieval Augmented Generation (RAG), and Google Gemini to deliver accurate and contextually relevant responses in a chatbot within a Next.js framework.

Before we discuss the technical steps, let’s review the tools and techniques we will use.

RAG

Retrieval-augmented generation (RAG) is a technique that enables AI models to retrieve and incorporate new information.

It modifies interactions with a large language model (LLM) so that the model responds to user queries with reference to a specified set of documents, using this information to supplement information from its pre-existing training data. This allows LLMs to use domain-specific and/or updated information.

Our use case in this article will include providing chatbot access to our data from Smart Search.

WP Engine’s AI Toolkit

Here’s an overview of WP Engine’s AI Toolkit and the core capabilities it brings to both traditional and headless WordPress sites:

  • Smart Search & AI-Powered Hybrid Search

At its heart, the AI Toolkit includes WP Engine Smart Search—a drop-in replacement for WordPress’s native search that’s typo-tolerant, weight-aware, and ultra-fast. Out of the box, you get three modes: Full-Text (stemming and fuzzy matching), Semantic (NLP-driven meaning over mere keywords), and Hybrid (a tunable blend of both). Behind the scenes, Smart Search automatically indexes your Posts, Pages, Custom Post Types, ACF fields, WooCommerce products, and more—so you can serve richer, more relevant results without writing a line of search logic yourself.

  • Vector Database, Fully Managed

You don’t need to stand up or scale your own vector store—WP Engine’s AI Toolkit manages that for you. As new content is published or edited, the plugin streams updates in real time to its vector database. Queries are encoded into embeddings, nearest-neighbor lookups happen in milliseconds, and the freshest site content is always just a search away. This under-the-hood Vector DB also powers the AI aspects of Hybrid Search, ensuring that semantic similarity and context ranking work against live data.

  • Headless Integration

For sites using WP Engine’s Headless Platform, all of these features—Smart Search querying, vector indexing, AI-powered hybrid ranking, and recommendations—are exposed through GraphQL. The AI Toolkit installs and configures both WPGraphQL and Smart Search automatically, so your front-end app can orchestrate retrieval and generation without extra middleware.

  • Recommendations

An AI-driven content discovery feature that helps you surface “Related” or “Trending” posts (or custom post types) anywhere on your site—whether you’re using the Gutenberg editor or building a headless front end via WPGraphQL.

Google Gemini API (AI API’s)

The Google Gemini API offers developers a powerful and versatile interface to access Google’s state-of-the-art Gemini AI models. These multimodal models are designed to seamlessly understand and generate content across various data types, including text, code, images, audio, and video. 

For our chatbot integration, the Gemini API provides advanced natural language understanding, allowing it to interpret user queries and generate human-like responses. It supports multi-turn conversations, maintaining context over extended interactions, which is crucial for building engaging and intelligent conversational experiences. We will leverage the API’s flexibility to customize chatbot behavior, tone, and style, enabling a wide range of use cases from customer service to creative content generation.


Prerequisites

To benefit from this article, you should be familiar with the basics of working with the command line, headless WordPress development, Next.js, and the WP Engine User Portal.

Steps for setting up:

1. Set up an account on WP Engine and get a WordPress install running.  

2. Add a Smart Search license. Refer to the docs here for adding a license.


3. Navigate to the WP Admin of your install.  Inside your WP Admin, go to WP Engine Smart Search > Settings.  You will find your Smart Search URL and access token here.  Copy and save it.  We will need it later.  You should see this page:

4. Next, navigate to Configuration, select the Hybrid card, and add the post_content field in the Semantic settings section. We are going to use this field as our AI-powered field for similarity searches. Make sure to hit Save Configuration afterward.

5. After saving the configuration, head on over to the Index data page, then click Index NowIt will give you this success message once completed :

6. Create an API account on Google Gemini (Or whatever AI model you choose, e.g., OpenAI API).  Once created, navigate to your project’s dashboard. If you are using the Gemini API, go to the Google AI Studio. In your project’s dashboard, go to API Keys.  You should see a page like this:

Generate a new key, copy, and save your API key because we will need this later.  The API key is free on Google Gemini,  but the free tier has limits.

7.  Head over to your terminal or CLI and create a new Next.js project by pasting this utility command in:

npx create-next-app@latest name-of-your-app


You will receive prompts in the terminal asking you how you want your Next.js app scaffolded.  Answer them accordingly:

Would you like to use TypeScript? Yes
Wold you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use the `src/` directory? Yes
Would you like to use App Router? Yes
Would you like to customize the default import alias (@/*)? No


Once your Next.js app is created, you will need to install the dependencies needed to ensure our app works.  Copy and paste this command in your terminal:

npm install @ai-sdk/google ai openai-edge react-icons react-markdown 

Once the Next project is done scaffolding, cd into the project and then open up your code editor.

8. In your Next.js project, create a  .env.local file with the following environment variables:

GOOGLE_GENERATIVE_AI_API_KEY="<your key here>" # if you chose another AI model, you can name this key whatever you want
SMART_SEARCH_URL="<your smart search url here>"
SMART_SEARCH_ACCESS_TOKEN="<your smart search access token here>"

Here is the link to the final code repo so you can check step by step and follow along.

Make Requests to the WP Engine Smart Search API

The first thing we need to do is set up the request to the Smart Search API using the Similarity query.  Create a file in the src/app directory called utils/context.ts.  Copy the code below and paste it into that file:

// These are the types that are used in the `getContext` function
type Doc = {
  id: string;
  data: Record<string, unknown>;
  score: number;
};

type Similarity = {
  total: number;
  docs: Doc[];
};

export type GraphQLSimilarityResponse = {
  data: {
    similarity: Similarity;
  };
  errors?: { message: string }[];
};

const QUERY = /* GraphQL */ `
  query GetContext($message: String!, $field: String!) {
    similarity(
      input: { nearest: { text: $message, field: $field } }
    ) {
      total
      docs {
        id
        data
        score
      }
    }
  }
`;

export const getContext = async (
  message: string,
): Promise<GraphQLSimilarityResponse> => {
  const url   = process.env.SMART_SEARCH_URL;
  const token = process.env.SMART_SEARCH_ACCESS_TOKEN;

  if (!url || !token) {
    throw new Error(
      "SMART_SEARCH_URL and SMART_SEARCH_ACCESS_TOKEN must be defined.",
    );
  }

  const res = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      query: QUERY,
      variables: { message, field: "post_content" } as const,
    }),
  });

  if (!res.ok) {
    throw new Error(`Smart Search responded with ${res.status} ${res.statusText}`);
  }
 return res.json() as Promise<GraphQLSimilarityResponse>;
};

This block defines TypeScript types (Doc, Similarity, and Response) to model the shape of a similarity‐search GraphQL response, and exports an async getContext function that performs the actual lookup. Inside getContext, it reads the Smart Search endpoint URL and access token from environment variables, then constructs a GraphQL query named GetContext that requests the nearest documents (by embedding similarity) for a given message against a specified field (“post_content”). 

It sends that query and its variables in the body of a POST request—complete with JSON content headers and a Bearer authorization header—to the Smart Search API endpoint, and finally returns the parsed JSON result. By encapsulating the fetch logic and typing the response, this function provides a clean, reusable way to retrieve semantically related WordPress content for use in a RAG‐style chatbot.

Creating the “R” in RAG

The next file we need to create is the “Retrieval” portion in our RAG pipeline.  Create a tools.ts file in the utils folder and copy and paste this code block:

import { tool } from "ai";
import { z } from "zod";
import { getContext } from "@/app/utils/context";

// Define the search tool
export const smartSearchTool = tool({
  description:
    "Search for information about TV shows using WP Engine Smart Search. Use this to answer questions about TV shows, their content, characters, plots, etc., when the information is not already known.",
  parameters: z.object({
    query: z
      .string()
      .describe(
        "The search query to find relevant TV show information based on the user's question."
      ),
  }),
  execute: async ({ query }: { query: string }) => {
    console.log(`[Tool Execution] Searching with query: "${query}"`);
    try {
      const context = await getContext(query);

      if (context.errors && context.errors.length > 0) {
        console.error(
          "[Tool Execution] Error fetching context:",
          context.errors
        );
        // Return a structured error message that the LLM can understand
        return {
          error: `Error fetching context: ${context.errors[0].message}`,
        };
      }

      if (
        !context.data?.similarity?.docs ||
        context.data.similarity.docs.length === 0
      ) {
        console.log("[Tool Execution] No documents found for query:", query);
        return {
          searchResults: "No relevant information found for your query.",
        };
      }

      const formattedResults = context.data.similarity.docs.map((doc) => {
        if (!doc) {
          return {};
        }

        return {
          id: doc.id,
          title: doc.data.post_title,
          content: doc.data.post_content,
          url: doc.data.post_url,
          categories: doc.data.categories.map((category: any) => category.name),
          searchScore: doc.score,
        };
      });

      // console.log("[Tool Execution] Search results:", formattedResults);

      return { searchResults: formattedResults }; // Return the formatted string
    } catch (error: any) {
      console.error("[Tool Execution] Exception:", error);
      return { error: `An error occurred while searching: ${error.message}` };
    }
  },
});

export const weatherTool = tool({
  description:
    "Get the current weather information for a specific location. Use this to answer questions about the weather in different cities.",
  parameters: z.object({
    location: z
      .string()
      .describe(
        "The location for which to get the current weather information."
      ),
  }),
  execute: async ({ location }: { location: string }) => {
    console.log(`[Tool Execution] Getting weather for location: "${location}"`);
    try {
      // Simulate fetching weather data
      const weatherData = {
        location,
        temperature: "22°C",
        condition: "Sunny",
        humidity: "60%",
        windSpeed: "15 km/h",
      };
      const formattedWeather = `The current weather in ${weatherData.location} is ${weatherData.temperature} with ${weatherData.condition}. Humidity is at ${weatherData.humidity} and wind speed is ${weatherData.windSpeed}.`;
      return { weather: formattedWeather };
    } catch (error: any) {
      console.error("[Tool Execution] Exception:", error);
      return {
        error: `An error occurred while fetching weather data: ${error.message}`,
      };
    }
  },
});

This module registers two “tools” with the AI SDK—one for performing semantic searches against your WP Engine Smart Search index and another for fetching (simulated) weather data. The smartSearchTool uses Zod to validate a single query string, then calls your getContext helper to run a similarity‐search GraphQL request; it handles errors or empty results gracefully, formats any returned documents (including ID, title, content, URL, categories, and relevance score), and exposes them as a structured searchResults array. 


The weatherTool declares a location parameter, simulates a lookup of current conditions (temperature, humidity, wind speed), and returns a human‐readable summary. By wrapping each in the tool() factory—complete with descriptions, parameter schemas, and execute functions—this file makes both search and weather functionality available for the LLM to invoke during a conversation.

API Endpoint for Chat UI – The AG in RAG

Next, let’s create the chat endpoint for the Chat UI, which is the AG in RAG.  In the src/app directory, create a api/chat/ subfolder, then add a route.ts file in there.  Copy and paste this code into the file:

// IMPORTANT! Set the runtime to edge
export const runtime = "edge";

import { convertToCoreMessages, Message, streamText } from "ai";
import { createGoogleGenerativeAI } from "@ai-sdk/google";

import { smartSearchTool, weatherTool } from "@/app/utils/tools";

/**
 * Initialize the Google Generative AI API
 */
const google = createGoogleGenerativeAI();

export async function POST(req: Request) {
  try {
    const { messages }: { messages: Array<Message> } = await req.json();

    const coreMessages = convertToCoreMessages(messages);

    const smartSearchPrompt = `
    - You can use the 'smartSearchTool' to find information relating to tv shows.
      - WP Engine Smart Search is a powerful tool for finding information about TV shows.
      - After the 'smartSearchTool' provides results (even if it's an error or no information found)
      - You MUST then formulate a conversational response to the user based on those results but also use the tool if the users query is deemed plausible.
        - If search results are found, summarize them for the user. 
        - If no information is found or an error occurs, inform the user clearly.`;

    const systemPromptContent = `
    - You are a friendly and helpful AI assistant 
    - You can use the 'weatherTool' to provide current weather information for a specific location.
    - Do not invent information. Stick to the data provided by the tool.`;

    const response = streamText({
      model: google("models/gemini-2.0-flash"),
      system: [smartSearchPrompt, systemPromptContent].join("\n"),
      messages: coreMessages,
      tools: {
        smartSearchTool,
        weatherTool,
      },
      onStepFinish: async (result) => {
        // Log token usage for each step
        if (result.usage) {
          console.log(
            `[Token Usage] Prompt tokens: ${result.usage.promptTokens}, Completion tokens: ${result.usage.completionTokens}, Total tokens: ${result.usage.totalTokens}`
          );
        }
      },
      maxSteps: 5,
    });
    // Convert the response into a friendly text-stream
    return response.toDataStreamResponse({});
  } catch (e) {
    throw e;
  }
}


This file defines an Edge‐runtime POST endpoint that wires up Google’s Gemini model with two custom tools—smartSearchTool for TV-show lookups via WP Engine Smart Search and weatherTool for fetching current weather. When a request arrives, it parses the incoming chat messages, converts them into the AI SDK’s core message format, and assembles two system‐level prompts: one describing how to use the search tool, the other explaining the weather tool. 

It then invokes streamText with the Gemini “flash” model, the combined system prompt, the user’s message history, and the tool definitions, allowing the LLM to call out to those tools during generation. A callback logs token usage after each reasoning step (up to five steps), and the function finally returns the AI’s response as a streamed HTTP response.

Create UI Components for Chat Interface

The Chat.tsx file

Now, let’s create the chat interface.  In the src/app directory, create a components folder.  Then create a Chat.tsx file.  Copy and paste this code block in that file:

"use client";

import React, { ChangeEvent } from "react";
import Messages from "./Messages";
import { Message } from "ai/react";
import LoadingIcon from "../Icons/LoadingIcon";
import ChatInput from "./ChatInput";

interface Chat {
  input: string;
  handleInputChange: (e: ChangeEvent<HTMLInputElement>) => void;
  handleMessageSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
  messages: Message[];
  status: "submitted" | "streaming" | "ready" | "error";
}

const Chat: React.FC<Chat> = ({
  input,
  handleInputChange,
  handleMessageSubmit,
  messages,
  status,
}) => {
  return (
    <div id="chat" className="flex flex-col w-full mx-2">
      <Messages messages={messages} />
      {status === "submitted" && <LoadingIcon />}
      <form
        onSubmit={handleMessageSubmit}
        className="ml-1 mt-5 mb-5 relative rounded-lg"
      >
        <ChatInput input={input} handleInputChange={handleInputChange} />
      </form>
    </div>
  );
};

export default Chat;

This file defines a client-side React Chat component that ties together your message list, input field, and loading indicator. It declares a Chat props interface—containing the current input value, change and submit handlers, the array of chat messages, and a status flag—and uses those props to control its rendering.

Inside the component, it first renders the <Messages> list to show the conversation history. If the status is "submitted", it displays a <LoadingIcon> spinner to indicate that a response is pending.

Finally, it renders a <form> wrapping the <ChatInput> component wired to the provided input value and change handler, so users can type and submit new messages.

Messages Component

Staying in the src/app/components directory, create a Messages.tsx file.  Copy and paste this code block in:

import { Message } from "ai";
import { useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";

export default function Messages({ messages }: { messages: Message[] }) {
  const messagesEndRef = useRef<HTMLDivElement | null>(null);
  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);
  return (
    <div
      className="border-1 border-gray-100 overflow-y-scroll flex-grow flex-col justify-end p-1"
      style={{ scrollbarWidth: "none" }}
    >
      {messages.map((msg, index) => (
        <div
          key={index}
          className={`${
            msg.role === "assistant" ? "bg-green-500" : "bg-blue-500"
          } my-2 p-3 shadow-md hover:shadow-lg transition-shadow duration-200 flex slide-in-bottom bg-blue-500 border border-gray-900 message-glow`}
        >
          <div className="ml- rounded-tl-lg  p-2 border-r flex items-center">
            {msg.role === "assistant" ? "🤖" : "🧒🏻"}
          </div>
          <div className="ml-2 text-white">
            <ReactMarkdown>{msg.content}</ReactMarkdown>
          </div>
        </div>
      ))}
      <div ref={messagesEndRef} />
    </div>
  );
}


The Messages component renders a scrollable list of chat messages, automatically keeping the view scrolled to the latest entry. It accepts a messages prop (an array of Message objects) and uses a ref to an empty <div> at the bottom; a useEffect hook watches for changes to the messages array and calls scrollIntoView on that ref so new messages smoothly come into view. 


Each message is wrapped in a styled <div> whose background color and avatar icon depend on the message’s role (“assistant” vs. “user”), and the text content is rendered via ReactMarkdown to support Markdown formatting.

Chat Input Component

Lastly, staying in the components/Chat directory,  we have the chat input.  Create a ChatInput.tsx file and copy and paste this code block in:

import { ChangeEvent } from "react";
import SendIcon from "../Icons/SendIcon";

interface InputProps {
  input: string;
  handleInputChange: (e: ChangeEvent<HTMLInputElement>) => void;
}

function Input({ input, handleInputChange }: InputProps) {
  return (
    <div className="bg-gray-800 p-4 rounded-xl shadow-lg w-full max-w-2xl mx-auto">
      <input
        type="text"
        value={input}
        onChange={handleInputChange}
        placeholder={"Ask Smart Search about TV shows..."}
        className="w-full bg-transparent text-gray-200 placeholder-gray-500 focus:outline-none text-md mb-3"
      />
      <div className="flex">
        <button
          type="submit"
          className="p-1 hover:bg-gray-700 rounded-md transition-colors ml-auto"
          aria-label="Send message"
          disabled={!input.trim()}
        >
          <SendIcon />
        </button>
      </div>
    </div>
  );
}

export default Input;

This file exports an Input component that renders a styled text field and send button for your chat UI. It takes a input string and an handleInputChange callback to keep the input controlled, showing a placeholder prompt (“Ask Smart Search about TV shows…”). The send button, decorated with a SendIcon, is disabled when the input is empty or just whitespace.

Update the page.tsx template

We need to modify the src/app/page.tsx file to add the Chat component to the page.  In the page.tsx file copy and paste this code:

"use client";
import Chat from "./components/Chat/Chat";
import { useChat } from "@ai-sdk/react";
import { useEffect } from "react";

const Page: React.FC = () => {
  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    setMessages,
    status,
  } = useChat();

  useEffect(() => {
    if (messages.length < 1) {
      setMessages([
        {
          role: "assistant",
          content: "Welcome to the Smart Search chatbot!",
          id: "welcome",
        },
      ]);
    }
  }, [messages, setMessages]);

  return (
    <div className="flex flex-col justify-between h-screen bg-white mx-auto max-w-full">
      <div className="flex w-full flex-grow overflow-hidden relative bg-slate-950">
        <Chat
          input={input}
          handleInputChange={handleInputChange}
          handleMessageSubmit={handleSubmit}
          messages={messages}
          status={status}
        />
      </div>
    </div>
  );
};

export default Page;


This file defines our page component that leverages the useChat hook from the @ai-sdk/react package to manage chat state, including messages, input text, submission handler, and status. 

Upon initial render, a useEffect hook checks if there are no messages and injects a default assistant greeting. The component returns a full-viewport flexbox layout with a styled background area in which it renders the Chat component, passing along the chat state and handlers. 

Update the layout.tsx file with metadata

We need to add metadata to our layout.  Copy and paste this code block in the src/app/layout.tsx file:

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Smart Search RAG",
  description: "Lets make a chatbot with Smart Search",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  );
}

This file configures the global layout and metadata for the app: it imports global styles, loads the Inter font, and sets the page title and description. The default RootLayout component wraps all page content in <html> and <body> tags, applying the Inter font’s class to the body.

CSS Note: The last thing to add for the styling is the globals.css file. Visit the code block here and copy and paste it into your project.

Test the ChatBot

The chatbot should be completed and testable in this state. In your terminal, run npm run dev and navigate to http://localhost:3000. Try asking the chatbot a few questions.  You should see this in your browser:

Conclusion

We hope this article helped you understand how to create a chatbot with WP Engine’s AI toolkit in headless WordPress!  Stay tuned for the next article on embedding this and using it in traditional WordPress!!

As always, we’re super stoked to hear your feedback and learn about the headless projects you’re working on, so hit us up in the Headless WordPress Discord!



The post Create a Headless WordPress chatbot with WP Engine’s AI Toolkit, RAG, and Google Gemini appeared first on Builders.

]]>
https://wpengine.com/builders/create-a-headless-wordpress-chatbot-with-wp-engines-ai-toolkit-rag-and-google-gemini/feed/ 0