diff --git a/.vscode/settings.json b/.vscode/settings.json
index 890e561520ff7..bcbdb7baeb9fa 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -18,6 +18,7 @@
     "coderdenttest",
     "coderdtest",
     "codersdk",
+    "contravariance",
     "cronstrue",
     "databasefake",
     "dbmem",
diff --git a/site/src/api/queries/audits.ts b/site/src/api/queries/audits.ts
new file mode 100644
index 0000000000000..3479c183b1b5c
--- /dev/null
+++ b/site/src/api/queries/audits.ts
@@ -0,0 +1,23 @@
+import { getAuditLogs } from "api/api";
+import { type AuditLogResponse } from "api/typesGenerated";
+import { useFilterParamsKey } from "components/Filter/filter";
+import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
+
+export function paginatedAudits(
+  searchParams: URLSearchParams,
+): UsePaginatedQueryOptions<AuditLogResponse, string> {
+  return {
+    searchParams,
+    queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
+    queryKey: ({ payload, pageNumber }) => {
+      return ["auditLogs", payload, pageNumber] as const;
+    },
+    queryFn: ({ payload, limit, offset }) => {
+      return getAuditLogs({
+        offset,
+        limit,
+        q: payload,
+      });
+    },
+  };
+}
diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts
index 2b6900df13ac8..6829cd1dd5fba 100644
--- a/site/src/api/queries/users.ts
+++ b/site/src/api/queries/users.ts
@@ -1,6 +1,6 @@
 import { QueryClient, type UseQueryOptions } from "react-query";
 import * as API from "api/api";
-import {
+import type {
   AuthorizationRequest,
   GetUsersResponse,
   UpdateUserPasswordRequest,
@@ -10,11 +10,36 @@ import {
 } from "api/typesGenerated";
 import { getAuthorizationKey } from "./authCheck";
 import { getMetadataAsJSON } from "utils/metadata";
+import { type UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
+import { prepareQuery } from "utils/filters";
+
+export function usersKey(req: UsersRequest) {
+  return ["users", req] as const;
+}
+
+export function paginatedUsers(): UsePaginatedQueryOptions<
+  GetUsersResponse,
+  UsersRequest
+> {
+  return {
+    queryPayload: ({ limit, offset, searchParams }) => {
+      return {
+        limit,
+        offset,
+        q: prepareQuery(searchParams.get("filter") ?? ""),
+      };
+    },
+
+    queryKey: ({ payload }) => usersKey(payload),
+    queryFn: ({ payload, signal }) => API.getUsers(payload, signal),
+  };
+}
 
 export const users = (req: UsersRequest): UseQueryOptions<GetUsersResponse> => {
   return {
-    queryKey: ["users", req],
+    queryKey: usersKey(req),
     queryFn: ({ signal }) => API.getUsers(req, signal),
+    cacheTime: 5 * 1000 * 60,
   };
 };
 
diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx
index 1824554721b13..e0cee67d7241d 100644
--- a/site/src/components/Filter/filter.tsx
+++ b/site/src/components/Filter/filter.tsx
@@ -45,7 +45,7 @@ type UseFilterConfig = {
   onUpdate?: (newValue: string) => void;
 };
 
-const useFilterParamsKey = "filter";
+export const useFilterParamsKey = "filter";
 
 export const useFilter = ({
   fallbackFilter = "",
diff --git a/site/src/hooks/hookPolyfills.ts b/site/src/hooks/hookPolyfills.ts
index e5ba705296229..94d8052b349b5 100644
--- a/site/src/hooks/hookPolyfills.ts
+++ b/site/src/hooks/hookPolyfills.ts
@@ -6,7 +6,7 @@
  * They do not have the same ESLinter exceptions baked in that the official
  * hooks do, especially for dependency arrays.
  */
-import { useCallback, useEffect, useRef } from "react";
+import { useCallback, useLayoutEffect, useRef } from "react";
 
 /**
  * A DIY version of useEffectEvent.
@@ -35,7 +35,12 @@ export function useEffectEvent<TArgs extends unknown[], TReturn = unknown>(
   callback: (...args: TArgs) => TReturn,
 ) {
   const callbackRef = useRef(callback);
-  useEffect(() => {
+
+  // useLayoutEffect should be overkill here 99% of the time, but if this were
+  // defined as a regular effect, useEffectEvent would not be able to work with
+  // any layout effects at all; the callback sync here would fire *after* the
+  // layout effect that needs the useEffectEvent function
+  useLayoutEffect(() => {
     callbackRef.current = callback;
   }, [callback]);
 
diff --git a/site/src/hooks/usePaginatedQuery.test.ts b/site/src/hooks/usePaginatedQuery.test.ts
new file mode 100644
index 0000000000000..2ea3ea57a874f
--- /dev/null
+++ b/site/src/hooks/usePaginatedQuery.test.ts
@@ -0,0 +1,407 @@
+import { renderHookWithAuth } from "testHelpers/renderHelpers";
+import { waitFor } from "@testing-library/react";
+
+import {
+  type PaginatedData,
+  type UsePaginatedQueryOptions,
+  usePaginatedQuery,
+} from "./usePaginatedQuery";
+
+beforeAll(() => {
+  jest.useFakeTimers();
+});
+
+afterAll(() => {
+  jest.useRealTimers();
+  jest.clearAllMocks();
+});
+
+function render<
+  TQueryFnData extends PaginatedData = PaginatedData,
+  TQueryPayload = never,
+>(
+  options: UsePaginatedQueryOptions<TQueryFnData, TQueryPayload>,
+  route?: `/?page=${string}`,
+) {
+  return renderHookWithAuth(({ options }) => usePaginatedQuery(options), {
+    route,
+    path: "/",
+    initialProps: { options },
+  });
+}
+
+/**
+ * There are a lot of test cases in this file. Scoping mocking to inner describe
+ * function calls to limit the cognitive load of maintaining all this stuff
+ */
+describe(`${usePaginatedQuery.name}`, () => {
+  describe("queryPayload method", () => {
+    const mockQueryFn = jest.fn(() => Promise.resolve({ count: 0 }));
+
+    it("Passes along an undefined payload if queryPayload is not used", async () => {
+      const mockQueryKey = jest.fn(() => ["mockQuery"]);
+
+      await render({
+        queryKey: mockQueryKey,
+        queryFn: mockQueryFn,
+      });
+
+      const payloadValueMock = expect.objectContaining({
+        payload: undefined,
+      });
+
+      expect(mockQueryKey).toHaveBeenCalledWith(payloadValueMock);
+      expect(mockQueryFn).toHaveBeenCalledWith(payloadValueMock);
+    });
+
+    it("Passes along type-safe payload if queryPayload is provided", async () => {
+      const mockQueryKey = jest.fn(({ payload }) => {
+        return ["mockQuery", payload];
+      });
+
+      const testPayloadValues = [1, "Blah", { cool: true }];
+      for (const payload of testPayloadValues) {
+        const { unmount } = await render({
+          queryPayload: () => payload,
+          queryKey: mockQueryKey,
+          queryFn: mockQueryFn,
+        });
+
+        const matcher = expect.objectContaining({ payload });
+        expect(mockQueryKey).toHaveBeenCalledWith(matcher);
+        expect(mockQueryFn).toHaveBeenCalledWith(matcher);
+        unmount();
+      }
+    });
+  });
+
+  describe("Querying for current page", () => {
+    const mockQueryKey = jest.fn(() => ["mock"]);
+    const mockQueryFn = jest.fn(() => Promise.resolve({ count: 50 }));
+
+    it("Parses page number if it exists in URL params", async () => {
+      const pageNumbers = [1, 2, 7, 39, 743];
+
+      for (const num of pageNumbers) {
+        const { result, unmount } = await render(
+          { queryKey: mockQueryKey, queryFn: mockQueryFn },
+          `/?page=${num}`,
+        );
+
+        expect(result.current.currentPage).toBe(num);
+        unmount();
+      }
+    });
+
+    it("Defaults to page 1 if no page value can be parsed from params", async () => {
+      const { result } = await render({
+        queryKey: mockQueryKey,
+        queryFn: mockQueryFn,
+      });
+
+      expect(result.current.currentPage).toBe(1);
+    });
+  });
+
+  describe("Prefetching", () => {
+    const mockQueryKey = jest.fn(({ pageNumber }) => ["query", pageNumber]);
+
+    type Context = { pageNumber: number; limit: number };
+    const mockQueryFnImplementation = ({ pageNumber, limit }: Context) => {
+      const data: { value: number }[] = [];
+      if (pageNumber * limit < 75) {
+        for (let i = 0; i < limit; i++) {
+          data.push({ value: i });
+        }
+      }
+
+      return Promise.resolve({ data, count: 75 });
+    };
+
+    const testPrefetch = async (
+      startingPage: number,
+      targetPage: number,
+      shouldMatch: boolean,
+    ) => {
+      // Have to reinitialize mock function every call to avoid false positives
+      // from shared mutable tracking state
+      const mockQueryFn = jest.fn(mockQueryFnImplementation);
+      const { result } = await render(
+        { queryKey: mockQueryKey, queryFn: mockQueryFn },
+        `/?page=${startingPage}`,
+      );
+
+      const pageMatcher = expect.objectContaining({ pageNumber: targetPage });
+      if (shouldMatch) {
+        await waitFor(() => expect(result.current.totalRecords).toBeDefined());
+        await waitFor(() => expect(mockQueryFn).toBeCalledWith(pageMatcher));
+      } else {
+        // Can't use waitFor to test this, because the expect call will
+        // immediately succeed for the not case, even though queryFn needs to be
+        // called async via React Query
+        setTimeout(() => {
+          expect(mockQueryFn).not.toBeCalledWith(pageMatcher);
+        }, 1000);
+
+        jest.runAllTimers();
+      }
+    };
+
+    it("Prefetches the previous page if it exists", async () => {
+      await testPrefetch(2, 1, true);
+    });
+
+    it("Prefetches the next page if it exists", async () => {
+      await testPrefetch(2, 3, true);
+    });
+
+    it("Avoids prefetch for previous page if it doesn't exist", async () => {
+      await testPrefetch(1, 0, false);
+      await testPrefetch(6, 5, false);
+    });
+
+    it("Avoids prefetch for next page if it doesn't exist", async () => {
+      await testPrefetch(3, 4, false);
+    });
+
+    it("Reuses the same queryKey and queryFn methods for the current page and all prefetching (on a given render)", async () => {
+      const startPage = 2;
+      const mockQueryFn = jest.fn(mockQueryFnImplementation);
+
+      await render(
+        { queryKey: mockQueryKey, queryFn: mockQueryFn },
+        `/?page=${startPage}`,
+      );
+
+      const currentMatcher = expect.objectContaining({ pageNumber: startPage });
+      expect(mockQueryKey).toBeCalledWith(currentMatcher);
+      expect(mockQueryFn).toBeCalledWith(currentMatcher);
+
+      const prevPageMatcher = expect.objectContaining({
+        pageNumber: startPage - 1,
+      });
+      await waitFor(() => expect(mockQueryKey).toBeCalledWith(prevPageMatcher));
+      await waitFor(() => expect(mockQueryFn).toBeCalledWith(prevPageMatcher));
+
+      const nextPageMatcher = expect.objectContaining({
+        pageNumber: startPage + 1,
+      });
+      await waitFor(() => expect(mockQueryKey).toBeCalledWith(nextPageMatcher));
+      await waitFor(() => expect(mockQueryFn).toBeCalledWith(nextPageMatcher));
+    });
+  });
+
+  describe("Safety nets/redirects for invalid pages", () => {
+    const mockQueryKey = jest.fn(() => ["mock"]);
+    const mockQueryFn = jest.fn(({ pageNumber, limit }) =>
+      Promise.resolve({
+        data: new Array(limit).fill(pageNumber),
+        count: 100,
+      }),
+    );
+
+    it("No custom callback: synchronously defaults to page 1 if params are corrupt/invalid", async () => {
+      const { result } = await render(
+        {
+          queryKey: mockQueryKey,
+          queryFn: mockQueryFn,
+        },
+        "/?page=Cat",
+      );
+
+      expect(result.current.currentPage).toBe(1);
+    });
+
+    it("No custom callback: auto-redirects user to last page if requested page overshoots total pages", async () => {
+      const { result } = await render(
+        { queryKey: mockQueryKey, queryFn: mockQueryFn },
+        "/?page=35",
+      );
+
+      await waitFor(() => expect(result.current.currentPage).toBe(4));
+    });
+
+    it("No custom callback: auto-redirects user to first page if requested page goes below 1", async () => {
+      const { result } = await render(
+        { queryKey: mockQueryKey, queryFn: mockQueryFn },
+        "/?page=-9999",
+      );
+
+      await waitFor(() => expect(result.current.currentPage).toBe(1));
+    });
+
+    it("With custom callback: Calls callback and does not update search params automatically", async () => {
+      const testControl = new URLSearchParams({
+        page: "1000",
+      });
+
+      const onInvalidPageChange = jest.fn();
+      await render({
+        onInvalidPageChange,
+        queryKey: mockQueryKey,
+        queryFn: mockQueryFn,
+        searchParams: testControl,
+      });
+
+      await waitFor(() => {
+        expect(onInvalidPageChange).toBeCalledWith(
+          expect.objectContaining({
+            pageNumber: expect.any(Number),
+            limit: expect.any(Number),
+            offset: expect.any(Number),
+            totalPages: expect.any(Number),
+            searchParams: expect.any(URLSearchParams),
+            setSearchParams: expect.any(Function),
+          }),
+        );
+      });
+
+      expect(testControl.get("page")).toBe("1000");
+    });
+  });
+
+  describe("Passing in searchParams property", () => {
+    const mockQueryKey = jest.fn(() => ["mock"]);
+    const mockQueryFn = jest.fn(({ pageNumber, limit }) =>
+      Promise.resolve({
+        data: new Array(limit).fill(pageNumber),
+        count: 100,
+      }),
+    );
+
+    it("Reads from searchParams property if provided", async () => {
+      const searchParams = new URLSearchParams({
+        page: "2",
+      });
+
+      const { result } = await render({
+        searchParams,
+        queryKey: mockQueryKey,
+        queryFn: mockQueryFn,
+      });
+
+      expect(result.current.currentPage).toBe(2);
+    });
+
+    it("Flushes state changes via provided searchParams property instead of internal searchParams", async () => {
+      const searchParams = new URLSearchParams({
+        page: "2",
+      });
+
+      const { result } = await render({
+        searchParams,
+        queryKey: mockQueryKey,
+        queryFn: mockQueryFn,
+      });
+
+      result.current.goToFirstPage();
+      expect(searchParams.get("page")).toBe("1");
+    });
+  });
+});
+
+describe(`${usePaginatedQuery.name} - Returned properties`, () => {
+  describe("Page change methods", () => {
+    const mockQueryKey = jest.fn(() => ["mock"]);
+
+    const mockQueryFn = jest.fn(({ pageNumber, limit }) => {
+      type Data = PaginatedData & { data: readonly number[] };
+
+      return new Promise<Data>((resolve) => {
+        setTimeout(() => {
+          resolve({
+            data: new Array(limit).fill(pageNumber),
+            count: 100,
+          });
+        }, 10_000);
+      });
+    });
+
+    test("goToFirstPage always succeeds regardless of fetch status", async () => {
+      const queryFns = [mockQueryFn, jest.fn(() => Promise.reject("Too bad"))];
+
+      for (const queryFn of queryFns) {
+        const { result, unmount } = await render(
+          { queryFn, queryKey: mockQueryKey },
+          "/?page=5",
+        );
+
+        expect(result.current.currentPage).toBe(5);
+        result.current.goToFirstPage();
+        await waitFor(() => expect(result.current.currentPage).toBe(1));
+        unmount();
+      }
+    });
+
+    test("goToNextPage works only if hasNextPage is true", async () => {
+      const { result } = await render(
+        {
+          queryKey: mockQueryKey,
+          queryFn: mockQueryFn,
+        },
+        "/?page=1",
+      );
+
+      expect(result.current.hasNextPage).toBe(false);
+      result.current.goToNextPage();
+      expect(result.current.currentPage).toBe(1);
+
+      await jest.runAllTimersAsync();
+      await waitFor(() => expect(result.current.hasNextPage).toBe(true));
+      result.current.goToNextPage();
+      await waitFor(() => expect(result.current.currentPage).toBe(2));
+    });
+
+    test("goToPreviousPage works only if hasPreviousPage is true", async () => {
+      const { result } = await render(
+        {
+          queryKey: mockQueryKey,
+          queryFn: mockQueryFn,
+        },
+        "/?page=3",
+      );
+
+      expect(result.current.hasPreviousPage).toBe(false);
+      result.current.goToPreviousPage();
+      expect(result.current.currentPage).toBe(3);
+
+      await jest.runAllTimersAsync();
+      await waitFor(() => expect(result.current.hasPreviousPage).toBe(true));
+      result.current.goToPreviousPage();
+      await waitFor(() => expect(result.current.currentPage).toBe(2));
+    });
+
+    test("onPageChange accounts for floats and truncates numeric values before navigating", async () => {
+      const { result } = await render({
+        queryKey: mockQueryKey,
+        queryFn: mockQueryFn,
+      });
+
+      await jest.runAllTimersAsync();
+      await waitFor(() => expect(result.current.isSuccess).toBe(true));
+      result.current.onPageChange(2.5);
+
+      await waitFor(() => expect(result.current.currentPage).toBe(2));
+    });
+
+    test("onPageChange rejects impossible numeric values and does nothing", async () => {
+      const { result } = await render({
+        queryKey: mockQueryKey,
+        queryFn: mockQueryFn,
+      });
+
+      await jest.runAllTimersAsync();
+      await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+      result.current.onPageChange(NaN);
+      result.current.onPageChange(Infinity);
+      result.current.onPageChange(-Infinity);
+
+      setTimeout(() => {
+        expect(result.current.currentPage).toBe(1);
+      }, 1000);
+
+      jest.runAllTimers();
+    });
+  });
+});
diff --git a/site/src/hooks/usePaginatedQuery.ts b/site/src/hooks/usePaginatedQuery.ts
new file mode 100644
index 0000000000000..46a4b9b487f43
--- /dev/null
+++ b/site/src/hooks/usePaginatedQuery.ts
@@ -0,0 +1,444 @@
+import { useEffect } from "react";
+import { useEffectEvent } from "./hookPolyfills";
+import { type SetURLSearchParams, useSearchParams } from "react-router-dom";
+import { clamp } from "lodash";
+
+import {
+  type QueryFunctionContext,
+  type QueryKey,
+  type UseQueryOptions,
+  type UseQueryResult,
+  useQueryClient,
+  useQuery,
+} from "react-query";
+
+const DEFAULT_RECORDS_PER_PAGE = 25;
+
+/**
+ * The key to use for getting/setting the page number from the search params
+ */
+const PAGE_NUMBER_PARAMS_KEY = "page";
+
+/**
+ * A more specialized version of UseQueryOptions built specifically for
+ * paginated queries.
+ */
+export type UsePaginatedQueryOptions<
+  // Aside from TQueryPayload, all type parameters come from the base React
+  // Query type definition, and are here for compatibility
+  TQueryFnData extends PaginatedData = PaginatedData,
+  TQueryPayload = never,
+  TError = unknown,
+  TData = TQueryFnData,
+  TQueryKey extends QueryKey = QueryKey,
+> = BasePaginationOptions<TQueryFnData, TError, TData, TQueryKey> &
+  QueryPayloadExtender<TQueryPayload> & {
+    /**
+     * An optional dependency for React Router's URLSearchParams. If this is
+     * provided, all URL state changes will go through this object instead of
+     * an internal value.
+     */
+    searchParams?: URLSearchParams;
+
+    /**
+     * A function that takes pagination information and produces a full query
+     * key.
+     *
+     * Must be a function so that it can be used for the active query, and then
+     * reused for any prefetching queries (swapping the page number out).
+     */
+    queryKey: (params: QueryPageParamsWithPayload<TQueryPayload>) => TQueryKey;
+
+    /**
+     * A version of queryFn that is required and that exposes the pagination
+     * information through its query function context argument
+     */
+    queryFn: (
+      context: PaginatedQueryFnContext<TQueryKey, TQueryPayload>,
+    ) => TQueryFnData | Promise<TQueryFnData>;
+
+    /**
+     * A custom, optional function for handling what happens if the user
+     * navigates to a page that doesn't exist for the paginated data.
+     *
+     * If this function is not defined/provided when an invalid page is
+     * encountered, usePaginatedQuery will default to navigating the user to the
+     * closest valid page.
+     */
+    onInvalidPageChange?: (params: InvalidPageParams) => void;
+  };
+
+/**
+ * The result of calling usePaginatedQuery. Mirrors the result of the base
+ * useQuery as closely as possible, while adding extra pagination properties
+ */
+export type UsePaginatedQueryResult<
+  TData = unknown,
+  TError = unknown,
+> = UseQueryResult<TData, TError> & PaginationResultInfo;
+
+export function usePaginatedQuery<
+  TQueryFnData extends PaginatedData = PaginatedData,
+  TQueryPayload = never,
+  TError = unknown,
+  TData extends PaginatedData = TQueryFnData,
+  TQueryKey extends QueryKey = QueryKey,
+>(
+  options: UsePaginatedQueryOptions<
+    TQueryFnData,
+    TQueryPayload,
+    TError,
+    TData,
+    TQueryKey
+  >,
+): UsePaginatedQueryResult<TData, TError> {
+  const {
+    queryKey,
+    queryPayload,
+    onInvalidPageChange,
+    searchParams: outerSearchParams,
+    queryFn: outerQueryFn,
+    ...extraOptions
+  } = options;
+
+  const [innerSearchParams, setSearchParams] = useSearchParams();
+  const searchParams = outerSearchParams ?? innerSearchParams;
+
+  const limit = DEFAULT_RECORDS_PER_PAGE;
+  const currentPage = parsePage(searchParams);
+  const currentPageOffset = (currentPage - 1) * limit;
+
+  const getQueryOptionsFromPage = (pageNumber: number) => {
+    const pageParams: QueryPageParams = {
+      pageNumber,
+      limit,
+      offset: (pageNumber - 1) * limit,
+      searchParams: getParamsWithoutPage(searchParams),
+    };
+
+    const payload = queryPayload?.(pageParams) as RuntimePayload<TQueryPayload>;
+
+    return {
+      queryKey: queryKey({ ...pageParams, payload }),
+      queryFn: (context: QueryFunctionContext<TQueryKey>) => {
+        return outerQueryFn({ ...context, ...pageParams, payload });
+      },
+    } as const;
+  };
+
+  // Not using infinite query right now because that requires a fair bit of list
+  // virtualization as the lists get bigger (especially for the audit logs).
+  // Keeping initial implementation simple.
+  const query = useQuery<TQueryFnData, TError, TData, TQueryKey>({
+    ...extraOptions,
+    ...getQueryOptionsFromPage(currentPage),
+    keepPreviousData: true,
+  });
+
+  const totalRecords = query.data?.count;
+  const totalPages =
+    totalRecords !== undefined ? Math.ceil(totalRecords / limit) : undefined;
+
+  const hasNextPage =
+    totalRecords !== undefined && limit + currentPageOffset < totalRecords;
+  const hasPreviousPage =
+    totalRecords !== undefined &&
+    currentPage > 1 &&
+    currentPageOffset - limit < totalRecords;
+
+  const queryClient = useQueryClient();
+  const prefetchPage = useEffectEvent((newPage: number) => {
+    const options = getQueryOptionsFromPage(newPage);
+    return queryClient.prefetchQuery(options);
+  });
+
+  // Have to split hairs and sync on both the current page and the hasXPage
+  // variables, because the page can change immediately client-side, but the
+  // hasXPage values are derived from the server and won't always be immediately
+  // ready on the initial render
+  useEffect(() => {
+    if (hasNextPage) {
+      void prefetchPage(currentPage + 1);
+    }
+  }, [prefetchPage, currentPage, hasNextPage]);
+
+  useEffect(() => {
+    if (hasPreviousPage) {
+      void prefetchPage(currentPage - 1);
+    }
+  }, [prefetchPage, currentPage, hasPreviousPage]);
+
+  // Mainly here to catch user if they navigate to a page directly via URL;
+  // totalPages parameterized to insulate function from fetch status changes
+  const updatePageIfInvalid = useEffectEvent(async (totalPages: number) => {
+    // If totalPages is 0, that's a sign that the currentPage overshot, and the
+    // API returned a count of 0 because it didn't know how to process the query
+    let fixedTotalPages: number;
+    if (totalPages !== 0) {
+      fixedTotalPages = totalPages;
+    } else {
+      const firstPageOptions = getQueryOptionsFromPage(1);
+      try {
+        const firstPageResult = await queryClient.fetchQuery(firstPageOptions);
+        const rounded = Math.ceil(firstPageResult?.count ?? 0 / limit);
+        fixedTotalPages = Math.max(rounded, 1);
+      } catch {
+        fixedTotalPages = 1;
+      }
+    }
+
+    const clamped = clamp(currentPage, 1, fixedTotalPages);
+    if (currentPage === clamped) {
+      return;
+    }
+
+    const withoutPage = getParamsWithoutPage(searchParams);
+    if (onInvalidPageChange === undefined) {
+      withoutPage.set(PAGE_NUMBER_PARAMS_KEY, String(clamped));
+      setSearchParams(withoutPage);
+    } else {
+      const params: InvalidPageParams = {
+        limit,
+        setSearchParams,
+        offset: currentPageOffset,
+        searchParams: withoutPage,
+        totalPages: fixedTotalPages,
+        pageNumber: currentPage,
+      };
+
+      onInvalidPageChange(params);
+    }
+  });
+
+  useEffect(() => {
+    if (!query.isFetching && totalPages !== undefined) {
+      void updatePageIfInvalid(totalPages);
+    }
+  }, [updatePageIfInvalid, query.isFetching, totalPages]);
+
+  const onPageChange = (newPage: number) => {
+    // Page 1 is the only page that can be safely navigated to without knowing
+    // totalPages; no reliance on server data for math calculations
+    if (totalPages === undefined && newPage !== 1) {
+      return;
+    }
+
+    const cleanedInput = clamp(Math.trunc(newPage), 1, totalPages ?? 1);
+    if (Number.isNaN(cleanedInput)) {
+      return;
+    }
+
+    searchParams.set(PAGE_NUMBER_PARAMS_KEY, String(cleanedInput));
+    setSearchParams(searchParams);
+  };
+
+  // Have to do a type assertion for final return type to make React Query's
+  // internal types happy; splitting type definitions up to limit risk of the
+  // type assertion silencing type warnings we actually want to pay attention to
+  const info: PaginationResultInfo = {
+    limit,
+    currentPage,
+    onPageChange,
+    goToFirstPage: () => onPageChange(1),
+
+    goToPreviousPage: () => {
+      if (hasPreviousPage) {
+        onPageChange(currentPage - 1);
+      }
+    },
+
+    goToNextPage: () => {
+      if (hasNextPage) {
+        onPageChange(currentPage + 1);
+      }
+    },
+
+    ...(query.isSuccess
+      ? {
+          isSuccess: true,
+          hasNextPage,
+          hasPreviousPage,
+          totalRecords: totalRecords as number,
+          totalPages: totalPages as number,
+        }
+      : {
+          isSuccess: false,
+          hasNextPage: false,
+          hasPreviousPage: false,
+          totalRecords: undefined,
+          totalPages: undefined,
+        }),
+  };
+
+  return { ...query, ...info } as UsePaginatedQueryResult<TData, TError>;
+}
+
+function parsePage(params: URLSearchParams): number {
+  const parsed = Number(params.get("page"));
+  return Number.isInteger(parsed) && parsed > 1 ? parsed : 1;
+}
+
+/**
+ * Strips out the page number from a query so that there aren't mismatches
+ * between it and usePaginatedQuery's currentPage property (especially for
+ * prefetching)
+ */
+function getParamsWithoutPage(params: URLSearchParams): URLSearchParams {
+  const withoutPage = new URLSearchParams(params);
+  withoutPage.delete(PAGE_NUMBER_PARAMS_KEY);
+  return withoutPage;
+}
+
+/**
+ * All the pagination-properties for UsePaginatedQueryResult. Split up so that
+ * the types can be used separately in multiple spots.
+ */
+type PaginationResultInfo = {
+  currentPage: number;
+  limit: number;
+  onPageChange: (newPage: number) => void;
+  goToPreviousPage: () => void;
+  goToNextPage: () => void;
+  goToFirstPage: () => void;
+} & (
+  | {
+      isSuccess: false;
+      hasNextPage: false;
+      hasPreviousPage: false;
+      totalRecords: undefined;
+      totalPages: undefined;
+    }
+  | {
+      isSuccess: true;
+      hasNextPage: boolean;
+      hasPreviousPage: boolean;
+      totalRecords: number;
+      totalPages: number;
+    }
+);
+
+/**
+ * Papers over how the queryPayload function is defined at the type level, so
+ * that UsePaginatedQueryOptions doesn't look as scary.
+ *
+ * You're going to see these tuple types in a few different spots in this file;
+ * it's a "hack" to get around the function contravariance that pops up when you
+ * normally try to share the TQueryPayload between queryPayload, queryKey, and
+ * queryFn via the direct/"obvious" way. By throwing the types into tuples
+ * (which are naturally covariant), it's a lot easier to share the types without
+ * TypeScript complaining all the time or getting so confused that it degrades
+ * the type definitions into a bunch of "any" types
+ */
+type QueryPayloadExtender<TQueryPayload = never> = [TQueryPayload] extends [
+  never,
+]
+  ? { queryPayload?: never }
+  : {
+      /**
+       * An optional function for defining reusable "patterns" for taking
+       * pagination data (current page, etc.), which will be evaluated and
+       * passed to queryKey and queryFn for active queries and prefetch queries.
+       *
+       * queryKey and queryFn can each access the result of queryPayload
+       * by accessing the "payload" property from their main function argument
+       */
+      queryPayload: (params: QueryPageParams) => TQueryPayload;
+    };
+
+/**
+ * Information about a paginated request. This information is passed into the
+ * queryPayload, queryKey, and queryFn properties of the hook.
+ */
+type QueryPageParams = {
+  /**
+   * The page number used when evaluating queryKey and queryFn. pageNumber will
+   * be the current page during rendering, but will be the next/previous pages
+   * for any prefetching.
+   */
+  pageNumber: number;
+
+  /**
+   * The number of data records to pull per query. Currently hard-coded based
+   * off the value from PaginationWidget's utils file
+   */
+  limit: number;
+
+  /**
+   * The page offset to use for querying. Just here for convenience; can also be
+   * derived from pageNumber and limit
+   */
+  offset: number;
+
+  /**
+   * The current URL search params. Useful for letting you grab certain search
+   * terms from the URL
+   */
+  searchParams: URLSearchParams;
+};
+
+/**
+ * Weird, hard-to-describe type definition, but it's necessary for making sure
+ * that the type information involving the queryPayload function narrows
+ * properly.
+ */
+type RuntimePayload<TPayload = never> = [TPayload] extends [never]
+  ? undefined
+  : TPayload;
+
+/**
+ * The query page params, appended with the result of the queryPayload function.
+ * This type is passed to both queryKey and queryFn. If queryPayload is
+ * undefined, payload will always be undefined
+ */
+type QueryPageParamsWithPayload<TPayload = never> = QueryPageParams & {
+  payload: RuntimePayload<TPayload>;
+};
+
+/**
+ * Any JSON-serializable object returned by the API that exposes the total
+ * number of records that match a query
+ */
+export type PaginatedData = {
+  count: number;
+};
+
+/**
+ * React Query's QueryFunctionContext (minus pageParam, which is weird and
+ * defaults to type any anyway), plus all properties from
+ * QueryPageParamsWithPayload.
+ */
+type PaginatedQueryFnContext<
+  TQueryKey extends QueryKey = QueryKey,
+  TPayload = never,
+> = Omit<QueryFunctionContext<TQueryKey>, "pageParam"> &
+  QueryPageParamsWithPayload<TPayload>;
+
+/**
+ * The set of React Query properties that UsePaginatedQueryOptions derives from.
+ *
+ * Three properties are stripped from it:
+ * - keepPreviousData - The value must always be true to keep pagination feeling
+ *   nice, so better to prevent someone from trying to touch it at all
+ * - queryFn - Removed to make it easier to swap in a custom queryFn type
+ *   definition with a custom context argument
+ * - queryKey - Removed so that it can be replaced with the function form of
+ *   queryKey
+ * - onSuccess/onError - APIs are deprecated and removed in React Query v5
+ */
+type BasePaginationOptions<
+  TQueryFnData extends PaginatedData = PaginatedData,
+  TError = unknown,
+  TData = TQueryFnData,
+  TQueryKey extends QueryKey = QueryKey,
+> = Omit<
+  UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
+  "keepPreviousData" | "queryKey" | "queryFn" | "onSuccess" | "onError"
+>;
+
+/**
+ * The argument passed to a custom onInvalidPageChange callback.
+ */
+type InvalidPageParams = QueryPageParams & {
+  totalPages: number;
+  setSearchParams: SetURLSearchParams;
+};
diff --git a/site/src/pages/AuditPage/AuditPage.tsx b/site/src/pages/AuditPage/AuditPage.tsx
index 174bf517a480c..63674b071629c 100644
--- a/site/src/pages/AuditPage/AuditPage.tsx
+++ b/site/src/pages/AuditPage/AuditPage.tsx
@@ -1,7 +1,4 @@
-import {
-  DEFAULT_RECORDS_PER_PAGE,
-  isNonInitialPage,
-} from "components/PaginationWidget/utils";
+import { isNonInitialPage } from "components/PaginationWidget/utils";
 import { useFeatureVisibility } from "hooks/useFeatureVisibility";
 import { FC } from "react";
 import { Helmet } from "react-helmet-async";
@@ -10,20 +7,27 @@ import { pageTitle } from "utils/page";
 import { AuditPageView } from "./AuditPageView";
 import { useUserFilterMenu } from "components/Filter/UserFilter";
 import { useFilter } from "components/Filter/filter";
-import { usePagination } from "hooks";
-import { useQuery } from "react-query";
-import { getAuditLogs } from "api/api";
 import { useActionFilterMenu, useResourceTypeFilterMenu } from "./AuditFilter";
+import { usePaginatedQuery } from "hooks/usePaginatedQuery";
+import { paginatedAudits } from "api/queries/audits";
 
 const AuditPage: FC = () => {
-  const searchParamsResult = useSearchParams();
-  const pagination = usePagination({ searchParamsResult });
+  const { audit_log: isAuditLogVisible } = useFeatureVisibility();
+
+  /**
+   * There is an implicit link between auditsQuery and filter via the
+   * searchParams object
+   *
+   * @todo Make link more explicit (probably by making it so that components
+   * and hooks can share the result of useSearchParams directly)
+   */
+  const [searchParams, setSearchParams] = useSearchParams();
+  const auditsQuery = usePaginatedQuery(paginatedAudits(searchParams));
   const filter = useFilter({
-    searchParamsResult,
-    onUpdate: () => {
-      pagination.goToPage(1);
-    },
+    searchParamsResult: [searchParams, setSearchParams],
+    onUpdate: auditsQuery.goToFirstPage,
   });
+
   const userMenu = useUserFilterMenu({
     value: filter.values.username,
     onChange: (option) =>
@@ -32,6 +36,7 @@ const AuditPage: FC = () => {
         username: option?.value,
       }),
   });
+
   const actionMenu = useActionFilterMenu({
     value: filter.values.action,
     onChange: (option) =>
@@ -40,6 +45,7 @@ const AuditPage: FC = () => {
         action: option?.value,
       }),
   });
+
   const resourceTypeMenu = useResourceTypeFilterMenu({
     value: filter.values["resource_type"],
     onChange: (option) =>
@@ -48,37 +54,25 @@ const AuditPage: FC = () => {
         resource_type: option?.value,
       }),
   });
-  const { audit_log: isAuditLogVisible } = useFeatureVisibility();
-  const { data, error } = useQuery({
-    queryKey: ["auditLogs", filter.query, pagination.page],
-    queryFn: () => {
-      const limit = DEFAULT_RECORDS_PER_PAGE;
-      const page = pagination.page;
-      return getAuditLogs({
-        offset: page <= 0 ? 0 : (page - 1) * limit,
-        limit: limit,
-        q: filter.query,
-      });
-    },
-  });
 
   return (
     <>
       <Helmet>
         <title>{pageTitle("Audit")}</title>
       </Helmet>
+
       <AuditPageView
-        auditLogs={data?.audit_logs}
-        count={data?.count}
-        page={pagination.page}
-        limit={pagination.limit}
-        onPageChange={pagination.goToPage}
-        isNonInitialPage={isNonInitialPage(searchParamsResult[0])}
+        auditLogs={auditsQuery.data?.audit_logs}
+        count={auditsQuery.totalRecords}
+        page={auditsQuery.currentPage}
+        limit={auditsQuery.limit}
+        onPageChange={auditsQuery.onPageChange}
+        isNonInitialPage={isNonInitialPage(searchParams)}
         isAuditLogVisible={isAuditLogVisible}
-        error={error}
+        error={auditsQuery.error}
         filterProps={{
           filter,
-          error,
+          error: auditsQuery.error,
           menus: {
             user: userMenu,
             action: actionMenu,
diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx
index dd60039056f02..ef366b8787d5d 100644
--- a/site/src/pages/UsersPage/UsersPage.tsx
+++ b/site/src/pages/UsersPage/UsersPage.tsx
@@ -6,7 +6,7 @@ import { groupsByUserId } from "api/queries/groups";
 import { getErrorMessage } from "api/errors";
 import { deploymentConfig } from "api/queries/deployment";
 import {
-  users,
+  paginatedUsers,
   suspendUser,
   activateUser,
   deleteUser,
@@ -17,14 +17,13 @@ import {
 
 import { useMutation, useQuery, useQueryClient } from "react-query";
 import { useSearchParams, useNavigate } from "react-router-dom";
-import { useOrganizationId, usePagination } from "hooks";
+import { useOrganizationId } from "hooks";
 import { useMe } from "hooks/useMe";
 import { usePermissions } from "hooks/usePermissions";
 import { useStatusFilterMenu } from "./UsersFilter";
 import { useFilter } from "components/Filter/filter";
 import { useDashboard } from "components/Dashboard/DashboardProvider";
 import { generateRandomString } from "utils/random";
-import { prepareQuery } from "utils/filters";
 
 import { Helmet } from "react-helmet-async";
 import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
@@ -34,6 +33,7 @@ import { ResetPasswordDialog } from "./ResetPasswordDialog";
 import { pageTitle } from "utils/page";
 import { UsersPageView } from "./UsersPageView";
 import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
+import { usePaginatedQuery } from "hooks/usePaginatedQuery";
 
 export const UsersPage: FC<{ children?: ReactNode }> = () => {
   const queryClient = useQueryClient();
@@ -43,19 +43,11 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
   const { entitlements } = useDashboard();
   const [searchParams] = searchParamsResult;
 
-  const pagination = usePagination({ searchParamsResult });
-  const usersQuery = useQuery(
-    users({
-      q: prepareQuery(searchParams.get("filter") ?? ""),
-      limit: pagination.limit,
-      offset: pagination.offset,
-    }),
-  );
-
   const organizationId = useOrganizationId();
   const groupsByUserIdQuery = useQuery(groupsByUserId(organizationId));
   const authMethodsQuery = useQuery(authMethods());
 
+  const me = useMe();
   const { updateUsers: canEditUsers, viewDeploymentValues } = usePermissions();
   const rolesQuery = useQuery(roles());
   const { data: deploymentValues } = useQuery({
@@ -63,13 +55,12 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
     enabled: viewDeploymentValues,
   });
 
-  const me = useMe();
+  const usersQuery = usePaginatedQuery(paginatedUsers());
   const useFilterResult = useFilter({
     searchParamsResult,
-    onUpdate: () => {
-      pagination.goToPage(1);
-    },
+    onUpdate: usersQuery.goToFirstPage,
   });
+
   const statusMenu = useStatusFilterMenu({
     value: useFilterResult.values.status,
     onChange: (option) =>
@@ -164,10 +155,10 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
           error: usersQuery.error,
           menus: { status: statusMenu },
         }}
-        count={usersQuery.data?.count}
-        page={pagination.page}
-        limit={pagination.limit}
-        onPageChange={pagination.goToPage}
+        count={usersQuery.totalRecords}
+        page={usersQuery.currentPage}
+        limit={usersQuery.limit}
+        onPageChange={usersQuery.onPageChange}
       />
 
       <DeleteDialog
diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx
index b07d4921bee0d..9b1f0cb27ef3f 100644
--- a/site/src/testHelpers/renderHelpers.tsx
+++ b/site/src/testHelpers/renderHelpers.tsx
@@ -125,6 +125,7 @@ export async function renderHookWithAuth<Result, Props>(
   {
     initialProps,
     path = "/",
+    route = "/",
     extraRoutes = [],
   }: RenderHookWithAuthOptions<Props> = {},
 ) {
@@ -144,10 +145,10 @@ export async function renderHookWithAuth<Result, Props>(
        */
       // eslint-disable-next-line react-hooks/rules-of-hooks -- This is actually processed as a component; the linter just isn't aware of that
       const [readonlyStatefulRouter] = useState(() => {
-        return createMemoryRouter([
-          { path, element: <>{children}</> },
-          ...extraRoutes,
-        ]);
+        return createMemoryRouter(
+          [{ path, element: <>{children}</> }, ...extraRoutes],
+          { initialEntries: [route] },
+        );
       });
 
       /**
diff --git a/site/src/utils/filters.ts b/site/src/utils/filters.ts
index beb850a65e218..164ef633b5244 100644
--- a/site/src/utils/filters.ts
+++ b/site/src/utils/filters.ts
@@ -1,3 +1,6 @@
-export const prepareQuery = (query?: string) => {
+export function prepareQuery(query: string): string;
+export function prepareQuery(query: undefined): undefined;
+export function prepareQuery(query: string | undefined): string | undefined;
+export function prepareQuery(query?: string): string | undefined {
   return query?.trim().replace(/  +/g, " ");
-};
+}