How to Create a Headless E-Commerce Search Experience With WP Engine’s Smart Search AI and Nuxt.js

Francis Agulto Avatar

·

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
        }
      }
    }
  }
}
Code language: PHP (php)

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
Code language: PHP (php)


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>"
Code language: HTML, XML (xml)


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: "",
    },
  },
});
Code language: JavaScript (javascript)


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 };
};
Code language: PHP (php)

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,
  };
};

Code language: JavaScript (javascript)

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>
Code language: HTML, XML (xml)

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>
Code language: HTML, XML (xml)

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>
Code language: HTML, XML (xml)

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>
Code language: HTML, XML (xml)

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>
Code language: HTML, XML (xml)

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>
Code language: HTML, XML (xml)

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>
Code language: HTML, XML (xml)

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.