From cc3a2c2e1f89cb6f8fd45f99e40fe5c29dedee9c Mon Sep 17 00:00:00 2001 From: Sarah Soutoul Date: Mon, 12 Oct 2026 17:31:44 -0600 Subject: [PATCH 1/2] feat(backend): support user list cursor pagination --- .changeset/user-list-cursor-pagination.md | 5 +++++ packages/backend/src/api/endpoints/UserApi.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/user-list-cursor-pagination.md diff --git a/.changeset/user-list-cursor-pagination.md b/.changeset/user-list-cursor-pagination.md new file mode 100644 index 00000000000..fe8e9dcc62d --- /dev/null +++ b/.changeset/user-list-cursor-pagination.md @@ -0,0 +1,5 @@ +--- +'@clerk/backend': minor +--- + +Add `startingAfter` to `users.getUserList()` so applications can page through user lists with a cursor. diff --git a/packages/backend/src/api/endpoints/UserApi.ts b/packages/backend/src/api/endpoints/UserApi.ts index 28f2b134078..60963728467 100644 --- a/packages/backend/src/api/endpoints/UserApi.ts +++ b/packages/backend/src/api/endpoints/UserApi.ts @@ -38,6 +38,8 @@ export type UserCountParams = { /** @generateWithEmptyComment */ export type UserListParams = ClerkPaginationRequest<{ + /** The ID of the user after which to start returning results. Use the last user's ID from the previous page to fetch the next page. */ + startingAfter?: string; /** Filters users with the specified email addresses. Accepts up to 100 email addresses. */ emailAddress?: string[]; /** Filters users with the specified phone numbers. Accepts up to 100 phone numbers. */ @@ -435,7 +437,7 @@ export class UserAPI extends AbstractAPI { * @returns A [`PaginatedResourceResponse`](https://clerk.com/docs/reference/backend/types/paginated-resource-response) object with a `data` property that contains an array of [`User`](https://clerk.com/docs/reference/backend/types/backend-user) objects, and a `totalCount` property that indicates the total number of users in your instance. */ public async getUserList(params: UserListParams = {}) { - const { limit, offset, orderBy, ...userCountParams } = params; + const { limit, offset, orderBy, startingAfter, ...userCountParams } = params; // TODO(dimkl): Temporary change to populate totalCount using a 2nd BAPI call to /users/count endpoint // until we update the /users endpoint to be paginated in a next BAPI version. // In some edge cases the data.length != totalCount due to a creation of a user between the 2 api responses From 1ea13f26bfbdb65161e0627d3110dd5437c1ecc1 Mon Sep 17 00:00:00 2001 From: Sarah Soutoul Date: Tue, 13 Oct 2026 11:47:10 -0600 Subject: [PATCH 2/2] test(backend): document and verify user list cursors --- .../backend/src/api/__tests__/factory.test.ts | 31 +++++++++++++++++++ packages/backend/src/api/endpoints/UserApi.ts | 5 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/api/__tests__/factory.test.ts b/packages/backend/src/api/__tests__/factory.test.ts index 23b375103b0..02c3f4ac5f5 100644 --- a/packages/backend/src/api/__tests__/factory.test.ts +++ b/packages/backend/src/api/__tests__/factory.test.ts @@ -68,6 +68,37 @@ describe('api.client', () => { expect(totalCount).toBe(2); }); + it('sends startingAfter only to the users list request', async () => { + server.use( + http.get( + `https://api.clerk.test/v1/users`, + validateHeaders(({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get('starting_after')).toBe('user_previous'); + expect(url.searchParams.has('startingAfter')).toBe(false); + return HttpResponse.json([userJson]); + }), + ), + http.get( + `https://api.clerk.test/v1/users/count`, + validateHeaders(({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.has('starting_after')).toBe(false); + expect(url.searchParams.has('startingAfter')).toBe(false); + return HttpResponse.json({ object: 'total_count', total_count: 2 }); + }), + ), + ); + + const { data, totalCount } = await apiClient.users.getUserList({ + startingAfter: 'user_previous', + orderBy: '-created_at', + }); + + expect(data[0].id).toBe('user_cafebabe'); + expect(totalCount).toBe(2); + }); + it('executes users.getUserList() with last_sign_in_at filters', async () => { const afterTimestamp = 1640000000; const beforeTimestamp = 1700000000; diff --git a/packages/backend/src/api/endpoints/UserApi.ts b/packages/backend/src/api/endpoints/UserApi.ts index 60963728467..e4cb25ea541 100644 --- a/packages/backend/src/api/endpoints/UserApi.ts +++ b/packages/backend/src/api/endpoints/UserApi.ts @@ -38,7 +38,10 @@ export type UserCountParams = { /** @generateWithEmptyComment */ export type UserListParams = ClerkPaginationRequest<{ - /** The ID of the user after which to start returning results. Use the last user's ID from the previous page to fetch the next page. */ + /** + * The ID of the last user on the previous page. Only valid when `orderBy` is `created_at`, `+created_at`, + * or `-created_at` and `offset` is omitted or `0`. + */ startingAfter?: string; /** Filters users with the specified email addresses. Accepts up to 100 email addresses. */ emailAddress?: string[];