GraphQL and Next.js: Filtering, Searching, and Sorting
Practical examples and performance optimization techniques.

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 DefinitionFirst, 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!
}
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;
}
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 IndexingCreate 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);
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'; }, }), ],
});
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 #
- Type Safety
• Use TypeScript throughout
• Generate types from GraphQL schema
• Validate input data on both client and server
• Use strict null checks
- Error Handling
• Implement proper error boundaries
• Use union types for error states
• Provide meaningful error messages
• Log errors appropriately
- Performance
• Use appropriate indexes
• Implement efficient caching
• Use cursor-based pagination
• Optimize database queries
• Debounce user input
- Security
• Validate and sanitize inputs
• Implement rate limiting
• Use proper authentication
• Apply field-level permissions
- Testing
• Write unit tests for resolvers
• Test edge cases
• Implement integration tests
• Test performance with large datasets
Resources #
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.