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.


