Back to blog

GraphQL and Next.js: Filtering, Searching, and Sorting

Practical examples and performance optimization techniques.

March 18, 2025
@berta.codes
9 min read
GraphQL and Next.js: Filtering, Searching, and Sorting

In modern web applications, efficient data manipulation is crucial for a great user experience. This guide will show you how to implement robust filtering, searching, and sorting features using GraphQL and Next.js, with a focus on type safety, performance, and best practices.

Why GraphQL for Data Manipulation?

GraphQL excels at complex data operations compared to traditional REST APIs. Here's why:

Single Flexible Endpoint: Instead of multiple endpoints for different filter combinations

Precise Data Selection: Clients specify exactly what data they need

Type Safety: Schema-based validation ensures data consistency

Efficient Network Usage: No over-fetching or under-fetching

Real-time Updates: Built-in subscription support for live data

Core Implementation #

1. Schema Definition

First, let's define a type-safe schema that supports comprehensive filtering, searching, and sorting:

# Custom scalar for date handling
scalar DateTime

# Input type for filtering
input ProductFilterInput {
  # Text search across multiple fields
  searchTerm: String

  # Specific field filters
  category: [String!]
  minPrice: Float
  maxPrice: Float
  inStock: Boolean
  tags: [String!]

  # Date range filters
  createdAfter: DateTime
  createdBefore: DateTime

  # Logical operators
  AND: [ProductFilterInput!]
  OR: [ProductFilterInput!]
}

# Enum for sort fields
enum ProductSortField {
  PRICE
  NAME
  CREATED_AT
  POPULARITY
}

enum SortDirection {
  ASC
  DESC
}

# Input type for sorting
input ProductSortInput {
  field: ProductSortField!
  direction: SortDirection!
}

# Pagination input using cursor
input PaginationInput {
  first: Int
  after: String
  last: Int
  before: String
}

# Product type with all necessary fields
type Product {
  id: ID!
  name: String!
  description: String!
  price: Float!
  category: String!
  tags: [String!]!
  inStock: Boolean!
  popularity: Int!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# Edge type for cursor pagination
type ProductEdge {
  cursor: String!
  node: Product!
}

# Connection type with pagination info
type ProductConnection {
  edges: [ProductEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

# Root Query type
type Query {
  products(
    filter: ProductFilterInput
    sort: [ProductSortInput!]
    pagination: PaginationInput
  ): ProductConnection!

}

2. Server-Side Implementation

Here's a production-ready implementation using TypeScript, Prisma, and proper error handling:

import { PrismaClient } from '@prisma/client';
import { GraphQLError } from 'graphql';
import DataLoader from 'dataloader';
import { createProductLoader } from '../dataloaders/product';

const prisma = new PrismaClient();

interface Context {
  loaders: {
    product: DataLoader<string, Product>;
  };
}

const resolvers = {
  Query: {
    products: async (
      _,
      {
        filter,
        sort = [{ field: 'CREATED_AT', direction: 'DESC' }],
        pagination = { first: 20 },
      },
      context: Context
    ) => {
      try {
        // Build the where clause
        const where = buildWhereClause(filter);

        // Build the orderBy clause
        const orderBy = buildOrderByClause(sort);

        // Handle cursor-based pagination
        const { first, after, last, before } = pagination;
        const cursorOptions = buildCursorOptions(first, after, last, before);

        // Execute query with prisma
        const [totalCount, items] = await Promise.all([
          prisma.product.count({ where }),
          prisma.product.findMany({
            where,
            orderBy,
            ...cursorOptions,
          }),
        ]);

        // Process edges and page info
        const edges = items.map(item => ({
          cursor: encodeCursor(item.id),
          node: item,
        }));

        const pageInfo = {
          hasNextPage: items.length > first,
          hasPreviousPage: !!after || !!before,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        };

        return {
          edges: edges.slice(0, first),
          pageInfo,
          totalCount,
        };
      } catch (error) {
        throw new GraphQLError('Failed to fetch products', {
          extensions: {
            code: 'DATABASE_ERROR',
            error: process.env.NODE_ENV === 'development' ? error : undefined,
          },
        });
      }
    },
  },
};

// Helper function to build the where clause
function buildWhereClause(filter: ProductFilterInput) {
  if (!filter) return {};

  const where: Prisma.ProductWhereInput = {};

  if (filter.searchTerm) {
    where.OR = [
      {
        name: {
          contains: filter.searchTerm,
          mode: 'insensitive',
        },
      },
      {
        description: {
          contains: filter.searchTerm,
          mode: 'insensitive',
        },
      },
      {
        tags: {
          hasSome: [filter.searchTerm],
        },
      },
    ];
  }

  if (filter.category?.length) {
    where.category = { in: filter.category };
  }

  if (filter.minPrice !== undefined || filter.maxPrice !== undefined) {
    where.price = {};
    if (filter.minPrice !== undefined) {
      where.price.gte = filter.minPrice;
    }
    if (filter.maxPrice !== undefined) {
      where.price.lte = filter.maxPrice;
    }
  }

  if (filter.inStock !== undefined) {
    where.inStock = filter.inStock;
  }

  if (filter.tags?.length) {
    where.tags = { hasEvery: filter.tags };
  }

  if (filter.createdAfter || filter.createdBefore) {
    where.createdAt = {};
    if (filter.createdAfter) {
      where.createdAt.gte = filter.createdAfter;
    }
    if (filter.createdBefore) {
      where.createdAt.lte = filter.createdBefore;
    }
  }

  // Handle logical operators
  if (filter.AND?.length) {
    where.AND = filter.AND.map(buildWhereClause);
  }
  if (filter.OR?.length) {
    where.OR = filter.OR.map(buildWhereClause);
  }

  return where;
}

// Helper function to build the orderBy clause
function buildOrderByClause(sort: ProductSortInput[]) {
  return sort.map(({ field, direction }) => {
    const dir = direction.toLowerCase();
    switch (field) {
      case 'PRICE':
        return { price: dir };
      case 'NAME':
        return { name: dir };
      case 'POPULARITY':
        return { popularity: dir };
      case 'CREATED_AT':
      default:
        return { createdAt: dir };
    }
  });
}

// Helper functions for cursor pagination
function encodeCursor(id: string): string {
  return Buffer.from(id).toString('base64');
}

function decodeCursor(cursor: string): string {
  return Buffer.from(cursor, 'base64').toString('utf-8');
}

function buildCursorOptions(
  first?: number,
  after?: string,
  last?: number,
  before?: string
) {
  const options: any = {};

  if (first) {
    options.take = first + 1; // Take one extra to check hasNextPage
  }

  if (after) {
    options.cursor = { id: decodeCursor(after) };
    options.skip = 1; // Skip the cursor
  }

  if (last && before) {
    options.take = -last;
    options.cursor = { id: decodeCursor(before) };
  }

  return options;

}

3. Next.js Client Implementation

Here's a type-safe and performant client implementation:

// hooks/useProducts.ts
import { useQuery, gql } from '@apollo/client';
import { useState, useCallback, useMemo } from 'react';
import debounce from 'lodash/debounce';
import { useRouter } from 'next/router';

const GET_PRODUCTS = gql
  query GetProducts(
    $filter: ProductFilterInput
    $sort: [ProductSortInput!]
    $pagination: PaginationInput
  ) {
    products(filter: $filter, sort: $sort, pagination: $pagination) {
      edges {
        cursor
        node {
          id
          name
          description
          price
          category
          tags
          inStock
          popularity
          createdAt
        }
      }
      pageInfo {
        hasNextPage
        hasPreviousPage
        startCursor
        endCursor
      }
      totalCount
    }
  }
;

export function useProducts(initialFilters = {}) {
  const router = useRouter();
  const [filters, setFilters] = useState(initialFilters);
  const [sort, setSort] = useState([
    { field: 'CREATED_AT', direction: 'DESC' },
  ]);

  // Sync filters with URL for shareable links
  useEffect(() => {
    const queryParams = new URLSearchParams(router.query as Record<string, string>);
    const urlFilters = Object.fromEntries(queryParams.entries());
    if (Object.keys(urlFilters).length) {
      setFilters(parseFilters(urlFilters));
    }
  }, [router.query]);

  // Debounced search handler
  const debouncedSearch = useMemo(
    () =>
      debounce((term: string) => {
        setFilters(prev => ({ ...prev, searchTerm: term }));
        updateQueryParams({ ...filters, searchTerm: term });
      }, 300),
    [filters]
  );

  const { data, loading, error, fetchMore } = useQuery(GET_PRODUCTS, {
    variables: {
      filter: filters,
      sort,
      pagination: { first: 20 },
    },
    notifyOnNetworkStatusChange: true,
  });

  const loadMore = useCallback(() => {
    if (!data?.products.pageInfo.hasNextPage) return;

    return fetchMore({
      variables: {
        pagination: {
          first: 20,
          after: data.products.pageInfo.endCursor,
        },
      },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev;

        return {
          products: {
            ...fetchMoreResult.products,
            edges: [
              ...prev.products.edges,
              ...fetchMoreResult.products.edges,
            ],
          },
        };
      },
    });
  }, [data, fetchMore]);

  return {
    products: data?.products.edges.map(edge => edge.node) ?? [],
    totalCount: data?.products.totalCount ?? 0,
    hasMore: data?.products.pageInfo.hasNextPage ?? false,
    loading,
    error,
    filters,
    setFilters,
    sort,
    setSort,
    loadMore,
    debouncedSearch,
  };
}

// components/ProductList.tsx
export function ProductList() {
  const {
    products,
    totalCount,
    hasMore,
    loading,
    error,
    filters,
    setFilters,
    sort,
    setSort,
    loadMore,
    debouncedSearch,
  } = useProducts();

  if (error) {
    return <ErrorMessage error={error} />;
  }

  return (
    <div className="space-y-6">
      <div className="flex justify-between items-center">
        <h1 className="text-2xl font-bold">
          Products ({totalCount})
        </h1>
        <div className="flex gap-4">
          <SearchInput
            value={filters.searchTerm}
            onChange={e => debouncedSearch(e.target.value)}
            placeholder="Search products..."
          />
          <FilterDropdown
            value={filters}
            onChange={setFilters}
          />
          <SortDropdown
            value={sort}
            onChange={setSort}
          />
        </div>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {products.map(product => (
          <ProductCard
            key={product.id}
            product={product}
          />
        ))}
      </div>

      {loading && <LoadingSpinner />}

      {hasMore && !loading && (
        <LoadMoreButton onClick={loadMore} />
      )}
    </div>
  );

}

Performance Optimization #

1. Database Indexing

Create appropriate indexes for filtered and sorted fields:

-- Essential indexes for PostgreSQL
CREATE INDEX idx_products_search ON products USING gin(
  to_tsvector('english', name || ' ' || description)
);
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_price ON products(price);
CREATE INDEX idx_products_created_at ON products(created_at);
CREATE INDEX idx_products_popularity ON products(popularity);
CREATE INDEX idx_products_tags ON products USING gin(tags);

-- Composite indexes for common filter combinations
CREATE INDEX idx_products_category_price ON products(category, price);

CREATE INDEX idx_products_category_created_at ON products(category, created_at);

2. Caching Strategy

Implement a multi-layer caching strategy:

import { ApolloServer } from '@apollo/server';
import responseCachePlugin from '@apollo/server-plugin-response-cache';
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import Keyv from 'keyv';

// Create Redis cache instance
const cache = new KeyvAdapter(new Keyv('redis://localhost:6379'));

const server = new ApolloServer({
  typeDefs,
  resolvers,
  cache,
  plugins: [
    responseCachePlugin({
      // Cache configuration
      sessionId: async requestContext => {
        // Use custom session ID or null for public cache
        return requestContext.request.http.headers.get('authorization') || null;
      },
      maxAge: 30, // Cache for 30 seconds
      // Cache scope policy
      scope: async requestContext => {
        const { filter, sort } = requestContext.request.variables;
        // Public cache for basic queries, private for user-specific filters
        return filter?.userId ? 'PRIVATE' : 'PUBLIC';
      },
    }),
  ],

});

3. Client-Side Optimization

Implement efficient client-side caching and state management:

import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: '/api/graphql',
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          products: {
            // Merge function for pagination
            keyArgs: ['filter', 'sort'],
            merge(existing, incoming, { args: { pagination } }) {
              if (!existing) return incoming;

              // Merge logic for cursor-based pagination
              return {
                ...incoming,
                edges: [...existing.edges, ...incoming.edges],
              };
            },
          },
        },
      },
    },
  }),

});

Best Practices #

  1. Type Safety

• Use TypeScript throughout

• Generate types from GraphQL schema

• Validate input data on both client and server

• Use strict null checks

  1. Error Handling

• Implement proper error boundaries

• Use union types for error states

• Provide meaningful error messages

• Log errors appropriately

  1. Performance

• Use appropriate indexes

• Implement efficient caching

• Use cursor-based pagination

• Optimize database queries

• Debounce user input

  1. Security

• Validate and sanitize inputs

• Implement rate limiting

• Use proper authentication

• Apply field-level permissions

  1. Testing

• Write unit tests for resolvers

• Test edge cases

• Implement integration tests

• Test performance with large datasets

Resources #

Next.js Documentation

Apollo Client Documentation

GraphQL Documentation

Prisma Documentation

Conclusion #

Building efficient filtering, searching, and sorting features with GraphQL and Next.js requires careful consideration of performance, type safety, and user experience. By following the patterns and practices outlined in this guide, you can create a robust and scalable solution that provides excellent performance and maintainability.

Key takeaways:

• Design your schema with flexibility in mind

• Implement proper database indexing

• Use appropriate caching strategies

• Optimize the client-side experience

• Maintain type safety throughout

• Test thoroughly with realistic data volumes

The combination of GraphQL's flexible query language and Next.js's powerful features makes it possible to create sophisticated data manipulation features that scale well and provide a great developer experience.

Share this post

This website uses cookies to analyze traffic and enhance your experience. By clicking "Accept", you consent to our use of cookies for analytics purposes. You can withdraw your consent at any time by changing your browser settings. Cookie Policy