← Back to list

Building a Reusable Offset Pagination Layer in NestJS with TypeORM and GraphQL

One helper, one factory, and every list query in your app gets pagination for free.

ThankGod Ajayi · 2026-05-25 06:01 · 0 claps · 8.2 min read paywalled
#nestjs #typeorm #graphql #pagination #typescript
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Building a Reusable Offset Pagination Layer in NestJS with TypeORM and GraphQL

One helper, one factory, and every list query in your app gets pagination for free.

Building a Reusable Offset Pagination Layer in NestJS with TypeORM and GraphQL

One helper, one factory, and every list query in your app gets pagination for free.

Every backend hits the same wall eventually: you ship a users query that returns ten records during development, and three months later it tries to materialise forty thousand rows into a single GraphQL response and your API falls over.

Pagination is the fix, and it’s the kind of thing you only want to write once. In this article, I’ll walk through the offset-pagination layer I use across a fairly large NestJS + TypeORM + GraphQL codebase. It’s roughly 60 lines of glue code that gives me:

  • A single offsetPaginate() function that works with both TypeORM Repository and SelectQueryBuilder.
  • A GraphQL-aware OffsetPaginated(Entity, 'Name') factory that generates a typed paginated object type for any entity.
  • Validation, sensible defaults, and a stable pageInfo shape that the frontend can rely on forever.

By the end, adding pagination to a new resolver is a one-liner.

A quick word on offset vs cursor

Before the code: yes, cursor-based pagination is the cool kid. It’s stable when the underlying data is being mutated (no “page 2 shows me what I already saw on page 1” jitter), and it scales better on huge tables because the database doesn’t have to count and skip rows.

But cursor pagination also makes the frontend’s life harder: no jumping to page 47, no “show me page X of Y,” no easy “47,318 results” counter. For most internal dashboards, admin tools, and even a lot of consumer surfaces, offset pagination is the right default. Use cursors when you actually feel the pain — infinite-scroll feeds, very large tables, or strict consistency requirements.

This pattern is offset. Let’s build it.

The four pieces

The whole module lives in infra/offset-pagination/ and is made of four small files:

  1. OffsetPaginationArgs — the GraphQL input type the client sends.
  2. OffsetPageInfo — the metadata block in every paginated response.
  3. OffsetPaginated() — a factory that turns any entity into a GraphQL paginated object type.
  4. offsetPaginate() — the runtime function that actually executes the query.

I’ll go through each one.

1. The input type — what the client sends

import { Field, InputType, Int } from '@nestjs/graphql';
import { IsInt, IsOptional, Max, Min } from 'class-validator';

@InputType()
export default class OffsetPaginationArgs {
  @IsOptional()
  @IsInt()
  @Min(1)
  @Field(() => Int, { nullable: true })
  page?: number;

  @IsOptional()
  @IsInt()
  @Min(1)
  @Max(100)
  @Field(() => Int, { nullable: true })
  limit?: number;
}

Two optional fields, page and limit, both validated with class-validator. The two decisions worth pointing out:

  • @Max(100) on limit. This is a hard ceiling enforced at the validation layer. A client cannot ask for limit: 5000 and DOS your database — the request is rejected before it even reaches the resolver. Pick a number that matches your largest reasonable UI page; 100 is a good starting point.
  • Both fields are optional. This lets resolvers accept “give me page 1 with a sensible default size” with zero ceremony. The defaults are filled in by the runtime helper, not here, which keeps the schema honest about what the client actually has to send.

2. The output metadata — what every list returns

import { Field, ObjectType } from '@nestjs/graphql';

@ObjectType()
export default class OffsetPageInfo {
  @Field()
  itemCount: number;     // items in *this* page

  @Field()
  totalItems: number;    // total matching the query

  @Field()
  itemsPerPage: number;  // the limit that was used

  @Field()
  currentPage: number;   // 1-indexed

  @Field()
  totalPages: number;    // ceil(totalItems / itemsPerPage)
}

This is the contract with the frontend. Every paginated query, in every part of the app, returns this exact shape inside pageInfo. That uniformity is the whole point — the client can write one generic pagination component (or hook, or store) and reuse it everywhere.

A subtle but useful detail: itemCount (this page) and totalItems (everything) are both there. The frontend almost always wants both — itemCount to know if it should hide pagination controls when the page isn't full, totalItems to display "Showing 21–40 of 137."

3. The factory — generics, but for GraphQL

This is the part that took me longest to figure out the first time. GraphQL’s type system is not generic. You can’t expose Paginated<User> to the schema; you have to expose a concrete UserPaginated type with a concrete items: [User!] field.

But you also don’t want to hand-write UserPaginated, OrderPaginated, NotificationPaginated, and so on. The answer is a TypeScript factory that manufactures a class at runtime, decorated for GraphQL, parameterised by the entity:

import { Type } from '@nestjs/common';
import { Field, ObjectType } from '@nestjs/graphql';
import { GraphQLJSON } from 'graphql-scalars';

import OffsetPageInfo from './offset-page-info';
import IOffsetPaginatedType from './types/offset-paginated.type';

export default function OffsetPaginated<T>(
  classRef: Type<T>,
  name: string,
): Type<IOffsetPaginatedType<T>> {
  @ObjectType(name, { isAbstract: true })
  abstract class OffsetPaginatedType implements IOffsetPaginatedType<T> {
    @Field(() => [classRef], { nullable: true })
    items: T[];

    @Field(() => OffsetPageInfo, { nullable: true })
    pageInfo: OffsetPageInfo;

    @Field(() => GraphQLJSON, { nullable: true })
    metaData?: Record<string, unknown>;
  }
  return OffsetPaginatedType as Type<IOffsetPaginatedType<T>>;
}

A few things are doing real work here:

  • isAbstract: true tells NestJS not to register OffsetPaginatedType itself in the schema. The factory is a template. The actual schema type is created when you extend or alias it with a concrete name.
  • @Field(() => [classRef], …) is the magic — classRef is the entity passed in, so each manufactured class has a concretely-typed items array. In the generated SDL you get items: [User!], items: [Notification!], etc.
  • metaData is GraphQLJSON. This is an escape hatch for cases where a query wants to attach a bit of extra context to the response (filter summaries, aggregations, computed flags) without having to invent a one-off GraphQL type. Use it sparingly — typed fields are still better when you know the shape.

Using the factory looks like this:

// notification-offset-paginated.type.ts
import OffsetPaginated from '../../../infra/offset-pagination/offset-paginated';
import { Notification } from '../entities/notification.entity';

export const NotificationOffsetPaginated = OffsetPaginated(
  Notification,
  'NotificationOffsetPaginated',
);

That’s it. One line of code, and NotificationOffsetPaginated is now a fully-typed GraphQL object type you can return from a resolver:

@Query(() => NotificationOffsetPaginated, {
  description: 'Get a paginated list of notifications for the current user',
})
getNotifications(
  @Args('NotificationSearchArgs') args: NotificationSearchArgs,
): Promise<IOffsetPaginatedType<Notification>> {
  return this.notificationService.findAll(args);
}

4. The runtime function — where the work happens

import { FindManyOptions, Repository, SelectQueryBuilder } from 'typeorm';
import type { OrderByCondition } from 'typeorm';

import OffsetPaginationArgs from './offset-pagination.args';
import IOffsetPaginatedType from './types/offset-paginated.type';

export const DEFAULT_LIMIT = 100;
const DEFAULT_PAGE = 1;

function normalizePagination(paginationArgs: OffsetPaginationArgs) {
  const limit = Math.max(1, paginationArgs.limit ?? DEFAULT_LIMIT);
  const page = Math.max(1, paginationArgs.page ?? DEFAULT_PAGE);
  const offset = (page - 1) * limit;
  return { page, limit, offset };
}

export default async function offsetPaginate<T extends object>(
  source: SelectQueryBuilder<T> | Repository<T>,
  paginationArgs: OffsetPaginationArgs = {},
  query?: OrderByCondition | FindManyOptions<T>,
  metaData?: Record<string, unknown>,
): Promise<IOffsetPaginatedType<T>> {
  const { page, limit, offset } = normalizePagination(paginationArgs);

  let totalCount: number;
  let items: T[];

  if (source instanceof SelectQueryBuilder) {
    const paginatedQuery = source.clone();
    if (query) {
      paginatedQuery.orderBy(query as OrderByCondition);
    } else {
      paginatedQuery.orderBy(`${source.alias}.createdAt`, 'DESC');
    }

    [items, totalCount] = await paginatedQuery
      .take(limit)
      .skip(offset)
      .getManyAndCount();
  } else {
    const findOptions: FindManyOptions<T> = {
      ...(query as FindManyOptions<T>),
      take: limit,
      skip: offset,
    };
    [items, totalCount] = await source.findAndCount(findOptions);
  }

  const totalPages = Math.ceil(totalCount / limit);

  return {
    items,
    pageInfo: {
      itemCount: items.length,
      totalItems: totalCount,
      itemsPerPage: limit,
      currentPage: page,
      totalPages,
    },
    metaData,
  };
}

This function does five things, and each one is deliberate.

It accepts either a Repository or a SelectQueryBuilder. This matters more than it sounds. Simple list queries (findAll, findActive) are happy with a repository and a FindManyOptions. Anything with joins, dynamic where clauses, search, or computed columns needs a query builder. Forcing every caller to use one or the other would be a constant source of friction. The instanceof SelectQueryBuilder check picks the right path.

It normalises pagination defensively. Math.max(1, …) and ?? are guards against bad inputs — invalid pages, missing limits, and the page = 0 edge case that produces a negative offset (which most databases interpret as "give me everything backwards," which is exactly the kind of bug you discover in production). class-validator already filters most of this at the resolver, but the helper doesn't trust its callers, and that's a good habit for shared infrastructure.

It clones the query builder before mutating it. source.clone() is the line I've forgotten the most often, and the consequences of forgetting are subtle. SelectQueryBuilder is mutable — calling .take(), .skip(), or .orderBy() modifies it in place. If the caller hands you their builder and you mutate it, you've quietly leaked pagination state into whatever they do next. The clone is cheap; the bug it prevents is not.

It enforces a default order. ORDER BY createdAt DESC if the caller didn't specify one. This is important because offset pagination without a stable order is undefined behaviour. Different database engines, different query plans, even different runs of the same query can return rows in different orders if you don't sort. "Page 2" stops meaning anything. A default order makes the helper safe by default; a custom order overrides it when needed.

It returns getManyAndCount() / findAndCount(). One round-trip for both the page and the total. TypeORM emits this as a single SQL query with a window function on Postgres, so you don't pay for two trips to the database.

Wiring it into a resolver

Here’s the full picture once everything is in place:

// 1. Define the paginated type
export const NotificationOffsetPaginated = OffsetPaginated(
  Notification,
  'NotificationOffsetPaginated',
);

// 2. Use it in a resolver
@Resolver(() => Notification)
export class NotificationResolver {
  constructor(private readonly notificationService: NotificationService) {}

  @Query(() => NotificationOffsetPaginated, {
    description: 'Get a paginated list of notifications for the current user',
  })
  getNotifications(
    @Args('NotificationSearchArgs') args: NotificationSearchArgs,
  ): Promise<IOffsetPaginatedType<Notification>> {
    return this.notificationService.findAll(args);
  }
}

// 3. The service just calls offsetPaginate
@Injectable()
export class NotificationService {
  constructor(
    @InjectRepository(Notification)
    private readonly notificationRepo: Repository<Notification>,
  ) {}

  findAll(args: NotificationSearchArgs) {
    const qb = this.notificationRepo
      .createQueryBuilder('notification')
      .where('notification.userId = :userId', { userId: args.userId });

    if (args.unreadOnly) {
      qb.andWhere('notification.readAt IS NULL');
    }

    return offsetPaginate(qb, args.pagination);
  }
}

That’s the whole thing. A new entity gets pagination by adding one type alias and calling offsetPaginate() instead of .getMany().

The gotchas worth knowing

A few things I’ve learned the hard way that don’t fit in the code:

Counting is not free. getManyAndCount runs SELECT COUNT(*) against your filtered query, and on tables with tens of millions of rows that count can become the slowest part of the request. For dashboards over big tables, consider either (a) caching the count for a few seconds, (b) showing "1000+" instead of an exact number past a threshold, or (c) switching to keyset pagination for that specific endpoint.

Deep pages are slow. OFFSET 100000 LIMIT 20 makes Postgres scan and discard 100,000 rows before returning your 20. If your UI lets users jump to arbitrary pages on a huge table, you'll feel it. Most UIs don't actually need this — page-through-the-first-few-pages is what users actually do. But it's worth knowing the limit before the limit knows you.

**createdAt DESC is a sensible default but not a sufficient one** when timestamps can collide. If two rows have identical createdAt (rare with millisecond precision, but possible with bulk inserts), their relative order across pages can flip. The fix is a tiebreaker — ORDER BY createdAt DESC, id DESC. Worth adding if you're seeing duplicate rows across pages.

The metaData field is a temptation. Because it's GraphQLJSON, you can stuff anything in there. Don't. Every time I've reached for it past the first or second use case, I've regretted not just adding a typed field to the response. Treat it as a release valve, not a habit.

Wrapping up

The full module is somewhere around 80 lines of code. What you get for it is a uniform pagination contract across your entire API, validated input, a default ordering, defensive handling of bad pages, and a one-line setup per entity. The factory pattern in particular — using TypeScript generics plus a runtime-decorated abstract class to bridge the gap between TypeScript’s type system and GraphQL’s — is a technique that’s useful well beyond pagination. Any time you find yourself writing a near-identical GraphQL @ObjectType for every entity, this is the shape of the answer.

Pagination is one of those problems that’s much smaller than it looks once you commit to solving it once instead of fifty times. Build it early, build it well, and then never think about it again.


메타데이터
post_id
9736c8ed6e2b
slug
building-a-reusable-offset-pagination-layer-in-nestjs-with-typeorm-and-graphql-9736c8ed6e2b
url
https://medium.com/@TGod-Ajayi/building-a-reusable-offset-pagination-layer-in-nestjs-with-typeorm-and-graphql-9736c8ed6e2b
canonical_url
https://medium.com/@TGod-Ajayi/building-a-reusable-offset-pagination-layer-in-nestjs-with-typeorm-and-graphql-9736c8ed6e2b
author_url
https://medium.com/@TGod-Ajayi
status
ok
fetched_at
2026-06-09 15:37:30