diff --git a/src/Query/EntityCondition.php b/src/Query/EntityCondition.php
new file mode 100644
index 0000000000000000000000000000000000000000..22615a7a0ad694785cc63643120c5adc06cc4bd5
--- /dev/null
+++ b/src/Query/EntityCondition.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\jsonapi_search_api\Query;
+
+use Drupal\jsonapi\Query\EntityCondition as BaseEntityCondition;
+
+/**
+ * A condition object for the EntityQuery.
+ */
+class EntityCondition extends BaseEntityCondition {
+
+  /**
+   * The allowed condition operators.
+   *
+   * The operators 'STARTS_WITH', 'CONTAINS', 'ENDS_WITH' are not supported by
+   * the Search API module.
+   *
+   * @var string[]
+   *
+   * @see \Drupal\search_api\Query\ConditionSetInterface
+   */
+  public static $allowedOperators = [
+    '=', '<>',
+    '>', '>=', '<', '<=',
+    'IN', 'NOT IN',
+    'BETWEEN', 'NOT BETWEEN',
+    'IS NULL', 'IS NOT NULL',
+  ];
+
+}
diff --git a/src/Query/Filter.php b/src/Query/Filter.php
new file mode 100644
index 0000000000000000000000000000000000000000..a961b160c8a7940641cacd2de5820506258cba7f
--- /dev/null
+++ b/src/Query/Filter.php
@@ -0,0 +1,290 @@
+<?php
+
+namespace Drupal\jsonapi_search_api\Query;
+
+use Drupal\search_api\Query\QueryInterface;
+use Drupal\jsonapi\Query\EntityConditionGroup;
+
+/**
+ * Gathers information about the filter parameter.
+ *
+ * Copied from \Drupal\jsonapi\Query\Filter class to support using Search API
+ * queries.
+ *
+ * @see \Drupal\jsonapi\Query\Filter
+ */
+class Filter {
+
+  /**
+   * The JSON:API filter key name.
+   *
+   * @var string
+   */
+  const KEY_NAME = 'filter';
+
+  /**
+   * The key for the implicit root group.
+   */
+  const ROOT_ID = '@root';
+
+  /**
+   * Key in the filter[<key>] parameter for conditions.
+   *
+   * @var string
+   */
+  const CONDITION_KEY = 'condition';
+
+  /**
+   * Key in the filter[<key>] parameter for groups.
+   *
+   * @var string
+   */
+  const GROUP_KEY = 'group';
+
+  /**
+   * Key in the filter[<id>][<key>] parameter for group membership.
+   *
+   * @var string
+   */
+  const MEMBER_KEY = 'memberOf';
+
+  /**
+   * The root condition group.
+   *
+   * @var string
+   */
+  protected $root;
+
+  /**
+   * Constructs a new Filter object.
+   *
+   * @param \Drupal\jsonapi\Query\EntityConditionGroup $root
+   *   An entity condition group which can be applied to an entity query.
+   */
+  public function __construct(EntityConditionGroup $root) {
+    $this->root = $root;
+  }
+
+  /**
+   * Gets the root condition group.
+   */
+  public function root() {
+    return $this->root;
+  }
+
+  /**
+   * Applies the root condition to the given query.
+   *
+   * @param \Drupal\search_api\Query\QueryInterface $query
+   *   The query for which the condition should be constructed.
+   *
+   * @return \Drupal\search_api\Query\ConditionGroupInterface
+   *   The compiled entity query condition.
+   */
+  public function queryCondition(QueryInterface $query) {
+    $condition = $this->buildGroup($query, $this->root());
+    return $condition;
+  }
+
+  /**
+   * Applies the root condition to the given query.
+   *
+   * @param \Drupal\search_api\Query\QueryInterface $query
+   *   The query to which the filter should be applied.
+   * @param \Drupal\jsonapi\Query\EntityConditionGroup $condition_group
+   *   The condition group to build.
+   *
+   * @return \Drupal\search_api\Query\ConditionGroupInterface
+   *   The query with the filter applied.
+   */
+  protected function buildGroup(QueryInterface $query, EntityConditionGroup $condition_group) {
+    // Create a condition group using the original query.
+    switch ($condition_group->conjunction()) {
+      case 'AND':
+        $group = $query->createConditionGroup();
+        break;
+
+      case 'OR':
+        $group = $query->createConditionGroup('OR');
+        break;
+    }
+
+    // Get all children of the group.
+    $members = $condition_group->members();
+
+    foreach ($members as $member) {
+      // If the child is simply a condition, add it to the new group.
+      if ($member instanceof EntityCondition) {
+        if ($member->operator() == 'IS NULL') {
+          $group->addCondition($member->field(), 'NULL', '=');
+        }
+        elseif ($member->operator() == 'IS NOT NULL') {
+          $group->addCondition($member->field(), 'NULL', '<>');
+        }
+        else {
+          $group->addCondition($member->field(), $member->value(), $member->operator());
+        }
+      }
+      // If the child is a group, then recursively construct a sub group.
+      elseif ($member instanceof EntityConditionGroup) {
+        // Add the subgroup to this new group.
+        $subgroup = $this->buildGroup($query, $member);
+        $group->addConditionGroup($subgroup);
+      }
+    }
+
+    // Return the constructed group so that it can be added to the query.
+    return $group;
+  }
+
+  /**
+   * Creates a Sort object from a query parameter.
+   *
+   * @param mixed $parameter
+   *   The `filter` query parameter from the Symfony request object.
+   *
+   * @return self
+   *   A Sort object with defaults.
+   */
+  public static function createFromQueryParameter($parameter) {
+    $expanded = static::expand($parameter);
+    return new static(static::buildEntityConditionGroup($expanded));
+  }
+
+  /**
+   * Expands any filter parameters using shorthand notation.
+   *
+   * @param array $original
+   *   The unexpanded filter data.
+   *
+   * @return array
+   *   The expanded filter data.
+   */
+  protected static function expand(array $original) {
+    $expanded = [];
+    foreach ($original as $key => $item) {
+      // Allow extreme shorthand filters, f.e. `?filter[promote]=1`.
+      if (!is_array($item)) {
+        $item = [
+          EntityCondition::VALUE_KEY => $item,
+        ];
+      }
+
+      // Throw an exception if the query uses the reserved filter id for the
+      // root group.
+      if ($key == static::ROOT_ID) {
+        $msg = sprintf("'%s' is a reserved filter id.", static::ROOT_ID);
+        throw new \UnexpectedValueException($msg);
+      }
+
+      // Add a memberOf key to all items.
+      if (isset($item[static::CONDITION_KEY][static::MEMBER_KEY])) {
+        $item[static::MEMBER_KEY] = $item[static::CONDITION_KEY][static::MEMBER_KEY];
+        unset($item[static::CONDITION_KEY][static::MEMBER_KEY]);
+      }
+      elseif (isset($item[static::GROUP_KEY][static::MEMBER_KEY])) {
+        $item[static::MEMBER_KEY] = $item[static::GROUP_KEY][static::MEMBER_KEY];
+        unset($item[static::GROUP_KEY][static::MEMBER_KEY]);
+      }
+      else {
+        $item[static::MEMBER_KEY] = static::ROOT_ID;
+      }
+
+      // Add the filter id to all items.
+      $item['id'] = $key;
+
+      // Expands shorthand filters.
+      $expanded[$key] = static::expandItem($key, $item);
+    }
+
+    return $expanded;
+  }
+
+  /**
+   * Expands a filter item in case a shortcut was used.
+   *
+   * Possible cases for the conditions:
+   *   1. filter[uuid][value]=1234.
+   *   2. filter[0][condition][field]=uuid&filter[0][condition][value]=1234.
+   *   3. filter[uuid][condition][value]=1234.
+   *   4. filter[uuid][value]=1234&filter[uuid][group]=my_group.
+   *
+   * @param string $filter_index
+   *   The index.
+   * @param array $filter_item
+   *   The raw filter item.
+   *
+   * @return array
+   *   The expanded filter item.
+   */
+  protected static function expandItem($filter_index, array $filter_item) {
+    if (isset($filter_item[EntityCondition::VALUE_KEY])) {
+      if (!isset($filter_item[EntityCondition::PATH_KEY])) {
+        $filter_item[EntityCondition::PATH_KEY] = $filter_index;
+      }
+
+      $filter_item = [
+        static::CONDITION_KEY => $filter_item,
+        static::MEMBER_KEY => $filter_item[static::MEMBER_KEY],
+      ];
+    }
+
+    if (!isset($filter_item[static::CONDITION_KEY][EntityCondition::OPERATOR_KEY])) {
+      $filter_item[static::CONDITION_KEY][EntityCondition::OPERATOR_KEY] = '=';
+    }
+
+    return $filter_item;
+  }
+
+  /**
+   * Denormalizes the given filter items into a single EntityConditionGroup.
+   *
+   * @param array $items
+   *   The normalized entity conditions and groups.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup
+   *   A root group containing all the denormalized conditions and groups.
+   */
+  protected static function buildEntityConditionGroup(array $items) {
+    $root = [
+      'id' => static::ROOT_ID,
+      static::GROUP_KEY => ['conjunction' => 'AND'],
+    ];
+    return static::buildTree($root, $items);
+  }
+
+  /**
+   * Organizes the flat, normalized filter items into a tree structure.
+   *
+   * @param array $root
+   *   The root of the tree to build.
+   * @param array $items
+   *   The normalized entity conditions and groups.
+   *
+   * @return \Drupal\jsonapi\Query\EntityConditionGroup
+   *   The entity condition group
+   */
+  protected static function buildTree(array $root, array $items) {
+    $id = $root['id'];
+
+    // Recursively build a tree of denormalized conditions and condition groups.
+    $members = [];
+    foreach ($items as $item) {
+      if ($item[static::MEMBER_KEY] == $id) {
+        if (isset($item[static::GROUP_KEY])) {
+          array_push($members, static::buildTree($item, $items));
+        }
+        elseif (isset($item[static::CONDITION_KEY])) {
+          $condition = EntityCondition::createFromQueryParameter($item[static::CONDITION_KEY]);
+          array_push($members, $condition);
+        }
+      }
+    }
+
+    $root[static::GROUP_KEY]['members'] = $members;
+
+    // Denormalize the root into a condition group.
+    return new EntityConditionGroup($root[static::GROUP_KEY]['conjunction'], $root[static::GROUP_KEY]['members']);
+  }
+
+}
diff --git a/src/Resource/IndexResource.php b/src/Resource/IndexResource.php
index 8214589c67aa93905b3039a26d8b0b890b74d2d1..0be69e878c565dec74310ca250a97242603e61a3 100644
--- a/src/Resource/IndexResource.php
+++ b/src/Resource/IndexResource.php
@@ -11,12 +11,13 @@ use Drupal\jsonapi\JsonApiResource\LinkCollection;
 use Drupal\jsonapi\Query\OffsetPage;
 use Drupal\jsonapi\ResourceResponse;
 use Drupal\jsonapi_resources\Resource\EntityResourceBase;
+use Drupal\jsonapi_search_api\Query\Filter;
 use Drupal\search_api\IndexInterface;
-use Drupal\search_api\Item\Field;
 use Drupal\search_api\Item\ItemInterface;
 use Drupal\search_api\ParseMode\ParseModeInterface;
 use Drupal\search_api\ParseMode\ParseModePluginManager;
 use Drupal\search_api\Query\QueryInterface;
+use Drupal\search_api\SearchApiException;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\Request;
 
@@ -82,12 +83,17 @@ final class IndexResource extends EntityResourceBase implements ContainerInjecti
     }
     $query->range($pagination->getOffset(), $pagination->getSize());
 
-    if ($request->query->has('filter')) {
+    if ($request->query->has(Filter::KEY_NAME)) {
       $this->applyFiltersToQuery($request, $query, $cacheability);
     }
 
     // Get the results and convert to JSON:API resource object data.
-    $results = $query->execute();
+    try {
+      $results = $query->execute();
+    }
+    catch (SearchApiException $exception) {
+      throw new CacheableBadRequestHttpException($cacheability, $exception->getMessage());
+    }
     // Load all entities at once, for better performance.
     $results->preLoadResultItems();
     $result_entities = array_map(static function (ItemInterface $item) {
@@ -121,21 +127,17 @@ final class IndexResource extends EntityResourceBase implements ContainerInjecti
     assert($parse_mode instanceof ParseModeInterface);
     $query->setParseMode($parse_mode);
 
-    $filter = $request->query->get('filter');
+    $filter = $request->query->get(Filter::KEY_NAME);
     if (isset($filter['fulltext'])) {
       $query->keys($filter['fulltext']);
       unset($filter['fulltext']);
     }
-
-    $index = $query->getIndex();
-    $filterable_fields = array_filter($index->getFields(), static function (Field $field) {
-      return $field->getType() !== 'text';
-    });
-    foreach ($filter as $filterable_field_name => $filterable_field_value) {
-      if (!isset($filterable_fields[$filterable_field_name])) {
-        throw new CacheableBadRequestHttpException($cacheability, sprintf('The %s field is not filterable.', $filterable_field_name));
-      }
-      $query->addCondition($filterable_field_name, $filterable_field_value);
+    try {
+      $filter = Filter::createFromQueryParameter($filter);
+      $query->addConditionGroup($filter->queryCondition($query));
+    }
+    catch (\Exception $exception) {
+      throw new CacheableBadRequestHttpException($cacheability, $exception->getMessage());
     }
   }
 
diff --git a/tests/src/Functional/IndexResourceTest.php b/tests/src/Functional/IndexResourceTest.php
index f39fabbd6af578f0853c272cb90e87c8e8672500..36428a72e413a8555e38ebd9d1508129157e32f6 100644
--- a/tests/src/Functional/IndexResourceTest.php
+++ b/tests/src/Functional/IndexResourceTest.php
@@ -200,6 +200,68 @@ final class IndexResourceTest extends BrowserTestBase {
    *   The data.
    */
   public function filterDataProvider(): \Generator {
+    yield [
+      [
+        'filter' => [
+          'category' => 'item_category',
+        ],
+      ],
+      2,
+      [1, 2],
+      [],
+    ];
+    yield [
+      [
+        'filter' => [
+          'category' => [
+            'operator' => '<>',
+            'value' => 'item_category',
+          ],
+        ],
+      ],
+      3,
+      [3, 4, 5],
+      [],
+    ];
+    yield [
+      [
+        'filter' => [
+          'id' => [
+            'operator' => '>',
+            'value' => '3',
+          ],
+        ],
+      ],
+      2,
+      [4, 5],
+      [],
+    ];
+    yield [
+      [
+        'filter' => [
+          'category' => [
+            'operator' => 'IN',
+            'value' => ['item_category', 'article_category'],
+          ],
+        ],
+      ],
+      4,
+      [1, 2, 4, 5],
+      [],
+    ];
+    yield [
+      [
+        'filter' => [
+          'category' => [
+            'operator' => 'NOT IN',
+            'value' => ['item_category', 'article_category'],
+          ],
+        ],
+      ],
+      1,
+      [3],
+      [],
+    ];
     yield [
       [
         'filter' => [