Repository: mealie-recipes/mealie
Pull request: feat: re-write get all routes to use pagination
Starting a draft since I'm expecting changes and a bunch of feedback. I haven't fully rolled out all the route changes yet, I've just started with shopping lists since I'm more familiar with those.

---

This feature aims to solve for task #1411: to implement the existing pagination logic in place of the ad-hoc get_all logic we're using today. I think this implementation is pretty straightforward, but I have a few things I'd like some feedback/help on.

I extended the `PaginationBase` per #1411, and I created a `ShoppingListPagination` schema using the base.

General questions:

1. Did I use the `GetAll` schema correctly? I'm still new to some of these patterns and I kind of guessed on this one. The `GetAll` schema is being used in the route. I'm unsure what purpose it serves that the `PaginationQuery` doesn't, but I see similar patterns on other routes so I'm probably missing something.
```python
@router.get("", response_model=ShoppingListPagination)
    def get_all(self, q: GetAll = Depends(GetAll)):
        return self.repo.pagination(
            pagination=q,
            override=ShoppingListSummary,
        )
```

2. I'm unsure of the best way to implement the `next` and `previous` attributes. The logic is simple (if we're not on page 1, include the previous page; if there are more records to fetch, include the next page) but I'm not sure what the best way to generate the route URL is without reusing a ton of code. I thought about capturing the original route's URL and parsing/modifying the parameters, but I wanted to hear your thoughts before I implemented that.

Next steps
---

- [x] confirm `GetAll` is being used correctly
- [x] implement `next` and `previous` pages in the pagination response
- [x] create pagination classes for all query-able objects and implement their route changes
- [x] check frontend references and adjust as needed
- [x] verify if any tests need to be revised
- [x] create new pagination tests
- [x] ~~remove old `get_all` ad-hoc methods~~ requires additional work to fully deprecate, out of the scope of this PR
- [x] debug `total_pages` calculation (see below)

Problem statement:
[v1.0.0b] [Task] - Rewrite Pagination For Endpoints
### What is the problem this task addresses?

Currently the `get_all` method for endpoints have different or someone non-traditional behavior. These should be consolidated to support a mix of query parameters and pagination for robust filtering and pagination so clients can take full advantage of the API without making expensive requests when not necessary.


### Proposed/Possible Solution(s)?

Utilize the `pagination` method on the generic repository return a PaginationBase[Schema] and use this as the return type for the endpoints. 

**Example `get_all` Payload**

```json
{
    "page": 1
    "per_page": 10
    "total": 0
    "total_pages": 0
    "data": [
        // ...items
    ]
}
```

Additionally, the PaginationBase should be extended to support a `next` and `previous` property that would embed a link/url to the next or previous page of data. This would allow clients to generate generic Data types that can very easily iterate on paginated data. 

```json
{
    "page": 1
    "per_page": 10
    "next": "/api/path/to/data?offset=10&limit=10&",
    "previous": null, // default to null if there isn't a previous page
    "total": 0
    "total_pages": 0
    "data": [
        // ...items
    ]
}
```

**Notes**
- PaginationQuery type would likely need to be sub-typed to ensure that we can extend query parameters as necessary. 
- We may be able to extend the pagination method to utilize filters from a derived class to allow for extensibility without too much duplicate code.

Hints from issue discussion:

Commit message: modified frontend getAll routes to use paging

Patch to review:
diff --git a/frontend/api/_base.ts b/frontend/api/_base.ts
index 3b3e40c81cc..7b251447210 100644
--- a/frontend/api/_base.ts
+++ b/frontend/api/_base.ts
@@ -1,4 +1,4 @@
-import { ApiRequestInstance } from "~/types/api";
+import { ApiRequestInstance, PaginationData } from "~/types/api";
 
 export interface CrudAPIInterface {
   requests: ApiRequestInstance;
@@ -18,13 +18,13 @@ export abstract class BaseAPI {
   }
 }
 
-export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType=CreateType> extends BaseAPI implements CrudAPIInterface {
+export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType> extends BaseAPI implements CrudAPIInterface {
   abstract baseRoute: string;
   abstract itemRoute(itemId: string | number): string;
 
-  async getAll(start = 0, limit = 9999, params = {} as any) {
-    return await this.requests.get<ReadType[]>(this.baseRoute, {
-      params: { start, limit, ...params },
+  async getAll(page = 1, perPage = -1, params = {} as any) {
+    return await this.requests.get<PaginationData<ReadType>>(this.baseRoute, {
+      params: { page, perPage, ...params },
     });
   }
 
diff --git a/frontend/components/Domain/Recipe/RecipeDialogShare.vue b/frontend/components/Domain/Recipe/RecipeDialogShare.vue
index 8851860b814..c723eaeead0 100644
--- a/frontend/components/Domain/Recipe/RecipeDialogShare.vue
+++ b/frontend/components/Domain/Recipe/RecipeDialogShare.vue
@@ -131,7 +131,7 @@ export default defineComponent({
     }
 
     async function refreshTokens() {
-      const { data } = await userApi.recipes.share.getAll(0, 999, { recipe_id: props.recipeId });
+      const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
 
       if (data) {
         state.tokens = data;
diff --git a/frontend/composables/partials/use-actions-factory.ts b/frontend/composables/partials/use-actions-factory.ts
index 884f6217b26..fd642381283 100644
--- a/frontend/composables/partials/use-actions-factory.ts
+++ b/frontend/composables/partials/use-actions-factory.ts
@@ -30,11 +30,18 @@ export function useStoreActions<T extends BoundT>(
     const allItems = useAsync(async () => {
       const { data } = await api.getAll();
 
-      if (allRef) {
-        allRef.value = data;
+      if (data && allRef) {
+        allRef.value = data.data;
+      }
+
+      if (data) {
+        return data.data ?? [];
+      }
+
+      else {
+        return [];
       }
 
-      return data ?? [];
     }, useAsyncKey());
 
     loading.value = false;
@@ -45,8 +52,8 @@ export function useStoreActions<T extends BoundT>(
     loading.value = true;
     const { data } = await api.getAll();
 
-    if (data && allRef) {
-      allRef.value = data;
+    if (data && data.data && allRef) {
+      allRef.value = data.data;
     }
 
     loading.value = false;
diff --git a/frontend/composables/recipes/use-recipe-tools.ts b/frontend/composables/recipes/use-recipe-tools.ts
index cd770799196..e705a1a28d4 100644
--- a/frontend/composables/recipes/use-recipe-tools.ts
+++ b/frontend/composables/recipes/use-recipe-tools.ts
@@ -21,7 +21,14 @@ export const useTools = function (eager = true) {
       loading.value = true;
       const units = useAsync(async () => {
         const { data } = await api.tools.getAll();
-        return data;
+
+        if (data) {
+          return data.data;
+        }
+
+        else {
+          return null;
+        };
       }, useAsyncKey());
 
       loading.value = false;
@@ -33,7 +40,7 @@ export const useTools = function (eager = true) {
       const { data } = await api.tools.getAll();
 
       if (data) {
-        tools.value = data;
+        tools.value = data.data;
       }
 
       loading.value = false;
diff --git a/frontend/composables/recipes/use-recipes.ts b/frontend/composables/recipes/use-recipes.ts
index a9c15d52712..d31a0e1934c 100644
--- a/frontend/composables/recipes/use-recipes.ts
+++ b/frontend/composables/recipes/use-recipes.ts
@@ -18,8 +18,8 @@ function swap(t: Array<unknown>, i: number, j: number) {
 export const useSorter = () => {
   function sortAToZ(list: Array<Recipe>) {
     list.sort((a, b) => {
-      const textA = a.name?.toUpperCase() ?? "";
-      const textB = b.name?.toUpperCase() ?? "";
+      const textA: string = a.name?.toUpperCase() ?? "";
+      const textB: string = b.name?.toUpperCase() ?? "";
       return textA < textB ? -1 : textA > textB ? 1 : 0;
     });
   }
@@ -61,10 +61,10 @@ export const useLazyRecipes = function () {
 
   const recipes = ref<Recipe[]>([]);
 
-  async function fetchMore(start: number, limit: number) {
-    const { data } = await api.recipes.getAll(start, limit);
+  async function fetchMore(page: number, perPage: number) {
+    const { data } = await api.recipes.getAll(page, perPage);
     if (data) {
-      data.forEach((recipe) => {
+      data.data.forEach((recipe) => {
         recipes.value?.push(recipe);
       });
     }
@@ -80,26 +80,26 @@ export const useRecipes = (all = false, fetchRecipes = true) => {
   const api = useUserApi();
 
   // recipes is non-reactive!!
-  const { recipes, start, end } = (() => {
+  const { recipes, page, perPage } = (() => {
     if (all) {
       return {
         recipes: allRecipes,
-        start: 0,
-        end: 9999,
+        page: 1,
+        perPage: -1,
       };
     } else {
       return {
         recipes: recentRecipes,
-        start: 0,
-        end: 30,
+        page: 1,
+        perPage: 30,
       };
     }
   })();
 
   async function refreshRecipes() {
-    const { data } = await api.recipes.getAll(start, end, { loadFood: true });
+    const { data } = await api.recipes.getAll(page, perPage, { loadFood: true });
     if (data) {
-      recipes.value = data;
+      recipes.value = data.data;
     }
   }
 
diff --git a/frontend/composables/use-group-cookbooks.ts b/frontend/composables/use-group-cookbooks.ts
index ec65b2e33be..34b5d3c3f46 100644
--- a/frontend/composables/use-group-cookbooks.ts
+++ b/frontend/composables/use-group-cookbooks.ts
@@ -31,7 +31,13 @@ export const useCookbooks = function () {
       const units = useAsync(async () => {
         const { data } = await api.cookbooks.getAll();
 
-        return data;
+        if (data) {
+          return data.data
+        }
+
+        else {
+          return null;
+        }
       }, useAsyncKey());
 
       loading.value = false;
@@ -41,8 +47,8 @@ export const useCookbooks = function () {
       loading.value = true;
       const { data } = await api.cookbooks.getAll();
 
-      if (data && cookbookStore) {
-        cookbookStore.value = data;
+      if (data && data.data && cookbookStore) {
+        cookbookStore.value = data.data;
       }
 
       loading.value = false;
diff --git a/frontend/composables/use-group-mealplan.ts b/frontend/composables/use-group-mealplan.ts
index 70aa01bfa5e..f61f3dd38ba 100644
--- a/frontend/composables/use-group-mealplan.ts
+++ b/frontend/composables/use-group-mealplan.ts
@@ -30,9 +30,15 @@ export const useMealplans = function (range: Ref<DateRange>) {
           limit: format(range.value.end, "yyyy-MM-dd"),
         };
         // @ts-ignore TODO Modify typing to allow for string start+limit for mealplans
-        const { data } = await api.mealplans.getAll(query.start, query.limit);
+        const { data } = await api.mealplans.getAll(1, -1, { start: query.start, limit: query.limit });
 
-        return data;
+        if (data) {
+          return data.data;
+        }
+
+        else {
+          return null;
+        }
       }, useAsyncKey());
 
       loading.value = false;
@@ -45,10 +51,10 @@ export const useMealplans = function (range: Ref<DateRange>) {
         limit: format(range.value.end, "yyyy-MM-dd"),
       };
       // @ts-ignore TODO Modify typing to allow for string start+limit for mealplans
-      const { data } = await api.mealplans.getAll(query.start, query.limit);
+      const { data } = await api.mealplans.getAll(1, -1, { start: query.start, limit: query.limit });
 
-      if (data) {
-        mealplans.value = data;
+      if (data && data.data) {
+        mealplans.value = data.data;
       }
 
       loading.value = false;
diff --git a/frontend/composables/use-group-webhooks.ts b/frontend/composables/use-group-webhooks.ts
index 12fcc3493b4..e178c083663 100644
--- a/frontend/composables/use-group-webhooks.ts
+++ b/frontend/composables/use-group-webhooks.ts
@@ -14,7 +14,13 @@ export const useGroupWebhooks = function () {
       const units = useAsync(async () => {
         const { data } = await api.groupWebhooks.getAll();
 
-        return data;
+        if (data) {
+          return data.data;
+        }
+
+        else {
+          return null;
+        }
       }, useAsyncKey());
 
       loading.value = false;
@@ -24,8 +30,8 @@ export const useGroupWebhooks = function () {
       loading.value = true;
       const { data } = await api.groupWebhooks.getAll();
 
-      if (data) {
-        webhooks.value = data;
+      if (data && data.data) {
+        webhooks.value = data.data;
       }
 
       loading.value = false;
diff --git a/frontend/composables/use-groups.ts b/frontend/composables/use-groups.ts
index 1fa65239039..9ef50c307a9 100644
--- a/frontend/composables/use-groups.ts
+++ b/frontend/composables/use-groups.ts
@@ -43,7 +43,14 @@ export const useGroups = function () {
     const asyncKey = String(Date.now());
     const groups = useAsync(async () => {
       const { data } = await api.groups.getAll();
-      return data;
+
+      if (data) {
+        return data.data;
+      }
+
+      else {
+        return null;
+      }
     }, asyncKey);
 
     loading.value = false;
@@ -53,7 +60,15 @@ export const useGroups = function () {
   async function refreshAllGroups() {
     loading.value = true;
     const { data } = await api.groups.getAll();
-    groups.value = data;
+
+    if (data) {
+      groups.value = data.data;
+    }
+
+    else {
+      groups.value = null;
+    }
+
     loading.value = false;
   }
 
diff --git a/frontend/composables/use-user.ts b/frontend/composables/use-user.ts
index a2dfbaccdb5..83d0651193e 100644
--- a/frontend/composables/use-user.ts
+++ b/frontend/composables/use-user.ts
@@ -17,7 +17,13 @@ export const useAllUsers = function () {
     const asyncKey = String(Date.now());
     const allUsers = useAsync(async () => {
       const { data } = await api.users.getAll();
-      return data;
+      if (data) {
+        return data.data;
+      }
+
+      else {
+        return null;
+      }
     }, asyncKey);
 
     loading.value = false;
@@ -27,7 +33,15 @@ export const useAllUsers = function () {
   async function refreshAllUsers() {
     loading.value = true;
     const { data } = await api.users.getAll();
-    users.value = data;
+
+    if (data) {
+      users.value = data.data;
+    }
+
+    else {
+      users.value = null;
+    }
+
     loading.value = false;
   }
 
diff --git a/frontend/pages/recipes/all.vue b/frontend/pages/recipes/all.vue
index 58bae8e0d67..088eba446ce 100644
--- a/frontend/pages/recipes/all.vue
+++ b/frontend/pages/recipes/all.vue
@@ -22,17 +22,15 @@ import { useLazyRecipes } from "~/composables/recipes";
 export default defineComponent({
   components: { RecipeCardSection },
   setup() {
-    const start = ref(0);
-    const increment = ref(30);
-    const offset = ref(increment.value);
-    const limit = ref(increment.value);
+    const page = ref(1);
+    const perPage = ref(30);
     const ready = ref(false);
     const loading = ref(false);
 
     const { recipes, fetchMore } = useLazyRecipes();
 
     onMounted(async () => {
-      await fetchMore(start.value, limit.value);
+      await fetchMore(page.value, perPage.value);
       ready.value = true;
     });
 
@@ -41,9 +39,8 @@ export default defineComponent({
         return;
       }
       loading.value = true;
-      start.value = offset.value + 1;
-      offset.value = offset.value + increment.value;
-      fetchMore(start.value, limit.value);
+      page.value = page.value + 1;
+      fetchMore(page.value, perPage.value);
       loading.value = false;
     }, 500);
 
diff --git a/frontend/types/api.ts b/frontend/types/api.ts
index 5614c579330..12a8b56a405 100644
--- a/frontend/types/api.ts
+++ b/frontend/types/api.ts
@@ -13,3 +13,11 @@ export interface ApiRequestInstance {
   patch<T, U = Partial<T>>(url: string, data: U): Promise<RequestResponse<T>>;
   delete<T>(url: string): Promise<RequestResponse<T>>;
 }
+
+export interface PaginationData<T> {
+  page: number,
+  per_page: number,
+  total: number,
+  total_pages: number,
+  data: T[]
+}
diff --git a/mealie/repos/repository_generic.py b/mealie/repos/repository_generic.py
index 5061e18066b..40a9940025a 100644
--- a/mealie/repos/repository_generic.py
+++ b/mealie/repos/repository_generic.py
@@ -1,3 +1,4 @@
+from math import ceil
 from typing import Any, Generic, TypeVar, Union
 
 from pydantic import UUID4, BaseModel
@@ -59,6 +60,8 @@ def _filter_builder(self, **kwargs) -> dict[str, Any]:
     def get_all(
         self, limit: int = None, order_by: str = None, order_descending: bool = True, start=0, override=None
     ) -> list[Schema]:
+        self.logger.warning('"get_all" method is deprecated; use "page_all" instead')
+
         # sourcery skip: remove-unnecessary-cast
         eff_schema = override or self.schema
 
@@ -224,7 +227,7 @@ def _count_attribute(
         else:
             return [eff_schema.from_orm(x) for x in q.all()]
 
-    def pagination(self, pagination: PaginationQuery, override=None) -> PaginationBase[Schema]:
+    def page_all(self, pagination: PaginationQuery, override=None) -> PaginationBase[Schema]:
         """
         pagination is a method to interact with the filtered database table and return a paginated result
         using the PaginationBase that provides several data points that are needed to manage pagination
@@ -240,11 +243,31 @@ def pagination(self, pagination: PaginationQuery, override=None) -> PaginationBa
 
         fltr = self._filter_builder()
         q = q.filter_by(**fltr)
-
         count = q.count()
 
+        # interpret -1 as "get_all"
+        if pagination.per_page == -1:
+            pagination.per_page = count
+
+        try:
+            total_pages = ceil(count / pagination.per_page)
+
+        except ZeroDivisionError:
+            total_pages = 0
+
+        # interpret -1 as "last page"
+        if pagination.page == -1:
+            pagination.page = total_pages
+
+        # failsafe for user input error
+        if pagination.page < 1:
+            pagination.page = 1
+
         if pagination.order_by:
             if order_attr := getattr(self.model, pagination.order_by, None):
+                # queries handle uppercase and lowercase differently, which is undesirable
+                order_attr = func.lower(order_attr)
+
                 if pagination.order_direction == OrderDirection.asc:
                     order_attr = order_attr.asc()
                 elif pagination.order_direction == OrderDirection.desc:
@@ -265,6 +288,6 @@ def pagination(self, pagination: PaginationQuery, override=None) -> PaginationBa
             page=pagination.page,
             per_page=pagination.per_page,
             total=count,
-            total_pages=int(count / pagination.per_page) + 1,
+            total_pages=total_pages,
             data=[eff_schema.from_orm(s) for s in data],
         )
diff --git a/mealie/repos/repository_recipes.py b/mealie/repos/repository_recipes.py
index 068d61d7595..f829c8784ff 100644
--- a/mealie/repos/repository_recipes.py
+++ b/mealie/repos/repository_recipes.py
@@ -1,3 +1,4 @@
+from math import ceil
 from random import randint
 from typing import Any, Optional
 from uuid import UUID
@@ -15,8 +16,9 @@
 from mealie.db.models.recipe.tag import Tag
 from mealie.db.models.recipe.tool import Tool
 from mealie.schema.recipe import Recipe
-from mealie.schema.recipe.recipe import RecipeCategory, RecipeSummary, RecipeTag, RecipeTool
+from mealie.schema.recipe.recipe import RecipeCategory, RecipePagination, RecipeSummary, RecipeTag, RecipeTool
 from mealie.schema.recipe.recipe_category import CategoryBase, TagBase
+from mealie.schema.response.pagination import OrderDirection, PaginationQuery
 
 from .repository_generic import RepositoryGeneric
 
@@ -128,6 +130,71 @@ def summary(
             .all()
         )
 
+    def page_all(self, pagination: PaginationQuery, override=None, load_food=False) -> RecipePagination:
+        q = self.session.query(self.model)
+
+        args = [
+            joinedload(RecipeModel.recipe_category),
+            joinedload(RecipeModel.tags),
+            joinedload(RecipeModel.tools),
+        ]
+
+        if load_food:
+            args.append(joinedload(RecipeModel.recipe_ingredient).options(joinedload(RecipeIngredient.food)))
+
+        q = q.options(*args)
+
+        fltr = self._filter_builder()
+        q = q.filter_by(**fltr)
+        count = q.count()
+
+        # interpret -1 as "get_all"
+        if pagination.per_page == -1:
+            pagination.per_page = count
+
+        try:
+            total_pages = ceil(count / pagination.per_page)
+
+        except ZeroDivisionError:
+            total_pages = 0
+
+        # interpret -1 as "last page"
+        if pagination.page == -1:
+            pagination.page = total_pages
+
+        # failsafe for user input error
+        if pagination.page < 1:
+            pagination.page = 1
+
+        if pagination.order_by:
+            if order_attr := getattr(self.model, pagination.order_by, None):
+                # queries handle uppercase and lowercase differently, which is undesirable
+                order_attr = func.lower(order_attr)
+
+                if pagination.order_direction == OrderDirection.asc:
+                    order_attr = order_attr.asc()
+                elif pagination.order_direction == OrderDirection.desc:
+                    order_attr = order_attr.desc()
+
+                q = q.order_by(order_attr)
+
+        q = q.limit(pagination.per_page).offset((pagination.page - 1) * pagination.per_page)
+
+        try:
+            data = q.all()
+        except Exception as e:
+            self._log_exception(e)
+            self.session.rollback()
+            raise e
+
+        return RecipePagination(
+            page=pagination.page,
+            per_page=pagination.per_page,
+            total=count,
+            total_pages=total_pages,
+            data=data,
+        )
+
     def get_by_categories(self, categories: list[RecipeCategory]) -> list[RecipeSummary]:
         """
         get_by_categories returns all the Recipes that contain every category provided in the list
diff --git a/mealie/routes/admin/admin_management_groups.py b/mealie/routes/admin/admin_management_groups.py
index 620754853d7..3abc158208c 100644
--- a/mealie/routes/admin/admin_management_groups.py
+++ b/mealie/routes/admin/admin_management_groups.py
@@ -5,9 +5,9 @@
 
 from mealie.schema.group.group import GroupAdminUpdate
 from mealie.schema.mapper import mapper
-from mealie.schema.query import GetAll
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.schema.response.responses import ErrorResponse
-from mealie.schema.user.user import GroupBase, GroupInDB
+from mealie.schema.user.user import GroupBase, GroupInDB, GroupPagination
 from mealie.services.group_services.group_service import GroupService
 
 from .._base import BaseAdminController, controller
@@ -39,9 +39,15 @@ def mixins(self):
             self.registered_exceptions,
         )
 
-    @router.get("", response_model=list[GroupInDB])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, override=GroupInDB)
+    @router.get("", response_model=GroupPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=GroupInDB,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=GroupInDB, status_code=status.HTTP_201_CREATED)
     def create_one(self, data: GroupBase):
diff --git a/mealie/routes/admin/admin_management_users.py b/mealie/routes/admin/admin_management_users.py
index b0d47d1b128..1d60f1d8906 100644
--- a/mealie/routes/admin/admin_management_users.py
+++ b/mealie/routes/admin/admin_management_users.py
@@ -7,9 +7,9 @@
 from mealie.routes._base import BaseAdminController, controller
 from mealie.routes._base.dependencies import SharedDependencies
 from mealie.routes._base.mixins import HttpRepo
-from mealie.schema.query import GetAll
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.schema.response.responses import ErrorResponse
-from mealie.schema.user.user import UserIn, UserOut
+from mealie.schema.user.user import UserIn, UserOut, UserPagination
 
 router = APIRouter(prefix="/users", tags=["Admin: Users"])
 
@@ -32,9 +32,15 @@ def repo(self):
     def mixins(self):
         return HttpRepo[UserIn, UserOut, UserOut](self.repo, self.deps.logger, self.registered_exceptions)
 
-    @router.get("", response_model=list[UserOut])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, override=UserOut)
+    @router.get("", response_model=UserPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=UserOut,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=UserOut, status_code=201)
     def create_one(self, data: UserIn):
diff --git a/mealie/routes/admin/admin_server_tasks.py b/mealie/routes/admin/admin_server_tasks.py
index e8a8bc15ede..0750dd4e337 100644
--- a/mealie/routes/admin/admin_server_tasks.py
+++ b/mealie/routes/admin/admin_server_tasks.py
@@ -1,8 +1,9 @@
-from fastapi import BackgroundTasks
+from fastapi import BackgroundTasks, Depends
 
 from mealie.routes._base import BaseAdminController, controller
 from mealie.routes._base.routers import UserAPIRouter
-from mealie.schema.server.tasks import ServerTask, ServerTaskNames
+from mealie.schema.response.pagination import PaginationQuery
+from mealie.schema.server.tasks import ServerTask, ServerTaskNames, ServerTaskPagination
 from mealie.services.server_tasks import BackgroundExecutor, test_executor_func
 
 router = UserAPIRouter()
@@ -10,9 +11,15 @@
 
 @controller(router)
 class AdminServerTasksController(BaseAdminController):
-    @router.get("/server-tasks", response_model=list[ServerTask])
-    def get_all(self):
-        return self.repos.server_tasks.get_all(order_by="created_at")
+    @router.get("/server-tasks", response_model=ServerTaskPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repos.server_tasks.page_all(
+            pagination=q,
+            override=ServerTask,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("/server-tasks", response_model=ServerTask, status_code=201)
     def create_test_tasks(self, bg_tasks: BackgroundTasks):
diff --git a/mealie/routes/comments/__init__.py b/mealie/routes/comments/__init__.py
index 93eba12781c..489159a1e44 100644
--- a/mealie/routes/comments/__init__.py
+++ b/mealie/routes/comments/__init__.py
@@ -8,13 +8,14 @@
 from mealie.routes._base.base_controllers import BaseUserController
 from mealie.routes._base.controller import controller
 from mealie.routes._base.mixins import HttpRepo
-from mealie.schema.query import GetAll
 from mealie.schema.recipe.recipe_comments import (
     RecipeCommentCreate,
     RecipeCommentOut,
+    RecipeCommentPagination,
     RecipeCommentSave,
     RecipeCommentUpdate,
 )
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.schema.response.responses import ErrorResponse, SuccessResponse
 
 router = APIRouter(prefix="/comments", tags=["Recipe: Comments"])
@@ -38,12 +39,18 @@ def _check_comment_belongs_to_user(self, item_id: UUID4) -> None:
         if comment.user_id != self.deps.acting_user.id and not self.deps.acting_user.admin:
             raise HTTPException(
                 status_code=403,
-                detail=ErrorResponse.response(message="Comment does not belong to user"),
+                detail=ErrorResponse(message="Comment does not belong to user"),
             )
 
-    @router.get("", response_model=list[RecipeCommentOut])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, override=RecipeCommentOut)
+    @router.get("", response_model=RecipeCommentPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=RecipeCommentOut,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=RecipeCommentOut, status_code=201)
     def create_one(self, data: RecipeCommentCreate):
diff --git a/mealie/routes/groups/controller_cookbooks.py b/mealie/routes/groups/controller_cookbooks.py
index d12a110a13e..6ab024e2ca5 100644
--- a/mealie/routes/groups/controller_cookbooks.py
+++ b/mealie/routes/groups/controller_cookbooks.py
@@ -8,6 +8,8 @@
 from mealie.routes._base.mixins import HttpRepo
 from mealie.schema import mapper
 from mealie.schema.cookbook import CreateCookBook, ReadCookBook, RecipeCookBook, SaveCookBook, UpdateCookBook
+from mealie.schema.cookbook.cookbook import CookBookPagination
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource
 from mealie.services.event_bus_service.message_types import EventTypes
 
@@ -37,11 +39,15 @@ def mixins(self):
             self.registered_exceptions,
         )
 
-    @router.get("", response_model=list[ReadCookBook])
-    def get_all(self):
-        items = self.repo.get_all()
-        items.sort(key=lambda x: x.position)
-        return items
+    @router.get("", response_model=CookBookPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=ReadCookBook,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=ReadCookBook, status_code=201)
     def create_one(self, data: CreateCookBook):
diff --git a/mealie/routes/groups/controller_group_notifications.py b/mealie/routes/groups/controller_group_notifications.py
index 62c54688fc7..0b4c101dc86 100644
--- a/mealie/routes/groups/controller_group_notifications.py
+++ b/mealie/routes/groups/controller_group_notifications.py
@@ -12,9 +12,10 @@
     GroupEventNotifierPrivate,
     GroupEventNotifierSave,
     GroupEventNotifierUpdate,
+    GroupEventPagination,
 )
 from mealie.schema.mapper import cast
-from mealie.schema.query import GetAll
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.services.event_bus_service.event_bus_service import EventBusService
 
 router = APIRouter(prefix="/groups/events/notifications", tags=["Group: Event Notifications"])
@@ -38,9 +39,15 @@ def repo(self):
     def mixins(self) -> HttpRepo:
         return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
 
-    @router.get("", response_model=list[GroupEventNotifierOut])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit)
+    @router.get("", response_model=GroupEventPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=GroupEventNotifierOut,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=GroupEventNotifierOut, status_code=201)
     def create_one(self, data: GroupEventNotifierCreate):
diff --git a/mealie/routes/groups/controller_labels.py b/mealie/routes/groups/controller_labels.py
index fe8e4f4df4d..4abd4ad0c23 100644
--- a/mealie/routes/groups/controller_labels.py
+++ b/mealie/routes/groups/controller_labels.py
@@ -13,8 +13,9 @@
     MultiPurposeLabelSummary,
     MultiPurposeLabelUpdate,
 )
+from mealie.schema.labels.multi_purpose_label import MultiPurposeLabelPagination
 from mealie.schema.mapper import cast
-from mealie.schema.query import GetAll
+from mealie.schema.response.pagination import PaginationQuery
 
 router = APIRouter(prefix="/groups/labels", tags=["Group: Multi Purpose Labels"])
 
@@ -35,9 +36,15 @@ def repo(self):
     def mixins(self) -> HttpRepo:
         return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
 
-    @router.get("", response_model=list[MultiPurposeLabelSummary])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, override=MultiPurposeLabelSummary)
+    @router.get("", response_model=MultiPurposeLabelPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=MultiPurposeLabelSummary,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=MultiPurposeLabelOut)
     def create_one(self, data: MultiPurposeLabelCreate):
diff --git a/mealie/routes/groups/controller_mealplan_rules.py b/mealie/routes/groups/controller_mealplan_rules.py
index 58a57e905cd..e3ea9ecac74 100644
--- a/mealie/routes/groups/controller_mealplan_rules.py
+++ b/mealie/routes/groups/controller_mealplan_rules.py
@@ -1,5 +1,6 @@
 from functools import cached_property
 
+from fastapi import Depends
 from pydantic import UUID4
 
 from mealie.routes._base.base_controllers import BaseUserController
@@ -7,7 +8,8 @@
 from mealie.routes._base.mixins import HttpRepo
 from mealie.routes._base.routers import UserAPIRouter
 from mealie.schema import mapper
-from mealie.schema.meal_plan.plan_rules import PlanRulesCreate, PlanRulesOut, PlanRulesSave
+from mealie.schema.meal_plan.plan_rules import PlanRulesCreate, PlanRulesOut, PlanRulesPagination, PlanRulesSave
+from mealie.schema.response.pagination import PaginationQuery
 
 router = UserAPIRouter(prefix="/groups/mealplans/rules", tags=["Groups: Mealplan Rules"])
 
@@ -22,9 +24,15 @@ def repo(self):
     def mixins(self):
         return HttpRepo[PlanRulesCreate, PlanRulesOut, PlanRulesOut](self.repo, self.deps.logger)
 
-    @router.get("", response_model=list[PlanRulesOut])
-    def get_all(self):
-        return self.repo.get_all(override=PlanRulesOut)
+    @router.get("", response_model=PlanRulesPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=PlanRulesOut,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=PlanRulesOut, status_code=201)
     def create_one(self, data: PlanRulesCreate):
diff --git a/mealie/routes/groups/controller_shopping_lists.py b/mealie/routes/groups/controller_shopping_lists.py
index a76781ec3a7..438b5e60023 100644
--- a/mealie/routes/groups/controller_shopping_lists.py
+++ b/mealie/routes/groups/controller_shopping_lists.py
@@ -12,12 +12,13 @@
     ShoppingListItemOut,
     ShoppingListItemUpdate,
     ShoppingListOut,
+    ShoppingListPagination,
     ShoppingListSave,
     ShoppingListSummary,
     ShoppingListUpdate,
 )
 from mealie.schema.mapper import cast
-from mealie.schema.query import GetAll
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.schema.response.responses import SuccessResponse
 from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource
 from mealie.services.event_bus_service.message_types import EventTypes
@@ -166,9 +167,15 @@ def repo(self):
     def mixins(self) -> HttpRepo[ShoppingListCreate, ShoppingListOut, ShoppingListSave]:
         return HttpRepo(self.repo, self.deps.logger, self.registered_exceptions, "An unexpected error occurred.")
 
-    @router.get("", response_model=list[ShoppingListSummary])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, override=ShoppingListSummary)
+    @router.get("", response_model=ShoppingListPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=ShoppingListSummary,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=ShoppingListOut, status_code=201)
     def create_one(self, data: ShoppingListCreate):
diff --git a/mealie/routes/groups/controller_webhooks.py b/mealie/routes/groups/controller_webhooks.py
index 7b177981171..6dba4506cab 100644
--- a/mealie/routes/groups/controller_webhooks.py
+++ b/mealie/routes/groups/controller_webhooks.py
@@ -7,8 +7,8 @@
 from mealie.routes._base.controller import controller
 from mealie.routes._base.mixins import HttpRepo
 from mealie.schema import mapper
-from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook
-from mealie.schema.query import GetAll
+from mealie.schema.group.webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination
+from mealie.schema.response.pagination import PaginationQuery
 
 router = APIRouter(prefix="/groups/webhooks", tags=["Groups: Webhooks"])
 
@@ -23,9 +23,15 @@ def repo(self):
     def mixins(self) -> HttpRepo:
         return HttpRepo[CreateWebhook, SaveWebhook, CreateWebhook](self.repo, self.deps.logger)
 
-    @router.get("", response_model=list[ReadWebhook])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, override=ReadWebhook)
+    @router.get("", response_model=WebhookPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=ReadWebhook,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=ReadWebhook, status_code=201)
     def create_one(self, data: CreateWebhook):
diff --git a/mealie/routes/organizers/controller_categories.py b/mealie/routes/organizers/controller_categories.py
index afdecfb5737..4ad6b749e6c 100644
--- a/mealie/routes/organizers/controller_categories.py
+++ b/mealie/routes/organizers/controller_categories.py
@@ -7,8 +7,9 @@
 from mealie.routes._base.mixins import HttpRepo
 from mealie.schema import mapper
 from mealie.schema.recipe import CategoryIn, RecipeCategoryResponse
-from mealie.schema.recipe.recipe import RecipeCategory
+from mealie.schema.recipe.recipe import RecipeCategory, RecipeCategoryPagination
 from mealie.schema.recipe.recipe_category import CategoryBase, CategorySave
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.services import urls
 from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource
 from mealie.services.event_bus_service.message_types import EventTypes
@@ -40,10 +41,16 @@ def repo(self):
     def mixins(self):
         return HttpRepo(self.repo, self.deps.logger)
 
-    @router.get("", response_model=list[RecipeCategory])
-    def get_all(self):
+    @router.get("", response_model=RecipeCategoryPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
         """Returns a list of available categories in the database"""
-        return self.repo.get_all(override=RecipeCategory)
+        response = self.repo.page_all(
+            pagination=q,
+            override=RecipeCategory,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", status_code=201)
     def create_one(self, category: CategoryIn):
diff --git a/mealie/routes/organizers/controller_tags.py b/mealie/routes/organizers/controller_tags.py
index 6fffbc6069d..92c318ecb72 100644
--- a/mealie/routes/organizers/controller_tags.py
+++ b/mealie/routes/organizers/controller_tags.py
@@ -7,8 +7,9 @@
 from mealie.routes._base.mixins import HttpRepo
 from mealie.schema import mapper
 from mealie.schema.recipe import RecipeTagResponse, TagIn
-from mealie.schema.recipe.recipe import RecipeTag
+from mealie.schema.recipe.recipe import RecipeTag, RecipeTagPagination
 from mealie.schema.recipe.recipe_category import TagSave
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.services import urls
 from mealie.services.event_bus_service.event_bus_service import EventBusService, EventSource
 from mealie.services.event_bus_service.message_types import EventTypes
@@ -29,10 +30,16 @@ def repo(self):
     def mixins(self):
         return HttpRepo(self.repo, self.deps.logger)
 
-    @router.get("")
-    async def get_all(self):
+    @router.get("", response_model=RecipeTagPagination)
+    async def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
         """Returns a list of available tags in the database"""
-        return self.repo.get_all(override=RecipeTag)
+        response = self.repo.page_all(
+            pagination=q,
+            override=RecipeTag,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.get("/empty")
     def get_empty_tags(self):
diff --git a/mealie/routes/organizers/controller_tools.py b/mealie/routes/organizers/controller_tools.py
index e0fc744cdd0..70f17081ae5 100644
--- a/mealie/routes/organizers/controller_tools.py
+++ b/mealie/routes/organizers/controller_tools.py
@@ -7,9 +7,9 @@
 from mealie.routes._base.controller import controller
 from mealie.routes._base.mixins import HttpRepo
 from mealie.schema import mapper
-from mealie.schema.query import GetAll
-from mealie.schema.recipe.recipe import RecipeTool
+from mealie.schema.recipe.recipe import RecipeTool, RecipeToolPagination
 from mealie.schema.recipe.recipe_tool import RecipeToolCreate, RecipeToolResponse, RecipeToolSave
+from mealie.schema.response.pagination import PaginationQuery
 
 router = APIRouter(prefix="/tools", tags=["Organizer: Tools"])
 
@@ -24,9 +24,15 @@ def repo(self):
     def mixins(self) -> HttpRepo:
         return HttpRepo[RecipeToolCreate, RecipeTool, RecipeToolCreate](self.repo, self.deps.logger)
 
-    @router.get("", response_model=list[RecipeTool])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, override=RecipeTool)
+    @router.get("", response_model=RecipeToolPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=RecipeTool,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=RecipeTool, status_code=201)
     def create_one(self, data: RecipeToolCreate):
diff --git a/mealie/routes/recipe/recipe_crud_routes.py b/mealie/routes/recipe/recipe_crud_routes.py
index 0613637ae26..57033f7a608 100644
--- a/mealie/routes/recipe/recipe_crud_routes.py
+++ b/mealie/routes/recipe/recipe_crud_routes.py
@@ -20,9 +20,8 @@
 from mealie.routes._base import BaseUserController, controller
 from mealie.routes._base.mixins import HttpRepo
 from mealie.routes._base.routers import UserAPIRouter
-from mealie.schema.query import GetAll
 from mealie.schema.recipe import Recipe, RecipeImageTypes, ScrapeRecipe
-from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary
+from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipePaginationQuery, RecipeSummary
 from mealie.schema.recipe.recipe_asset import RecipeAsset
 from mealie.schema.recipe.recipe_scraper import ScrapeRecipeTest
 from mealie.schema.recipe.request_helpers import RecipeZipTokenResponse, UpdateImageResponse
@@ -53,10 +52,6 @@ def mixins(self):
         return HttpRepo[CreateRecipe, Recipe, Recipe](self.repo, self.deps.logger)
 
 
-class RecipeGetAll(GetAll):
-    load_food: bool = False
-
-
 class FormatResponse(BaseModel):
     jjson: list[str] = Field(..., alias="json")
     zip: list[str]
@@ -196,18 +191,16 @@ def create_recipe_from_zip(self, temp_path=Depends(temporary_zip_path), archive:
     # CRUD Operations
 
     @router.get("", response_model=list[RecipeSummary])
-    def get_all(self, q: RecipeGetAll = Depends(RecipeGetAll)):
-        items = self.repo.summary(
-            self.user.group_id,
-            start=q.start,
-            limit=q.limit,
-            load_foods=q.load_food,
-            order_by=q.order_by,
-            order_descending=q.order_descending,
+    def get_all(self, q: RecipePaginationQuery = Depends(RecipePaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            load_food=q.load_food,
         )
 
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+
         new_items = []
-        for item in items:
+        for item in response.data:
             # Pydantic/FastAPI can't seem to serialize the ingredient field on thier own.
             new_item = item.__dict__
 
@@ -216,10 +209,11 @@ def get_all(self, q: RecipeGetAll = Depends(RecipeGetAll)):
 
             new_items.append(new_item)
 
-        json_compatible_item_data = jsonable_encoder(RecipeSummary.construct(**x) for x in new_items)
+        response.data = [RecipeSummary.construct(**x) for x in new_items]
+        json_compatible_response = jsonable_encoder(response)
 
         # Response is returned directly, to avoid validation and improve performance
-        return JSONResponse(content=json_compatible_item_data)
+        return JSONResponse(content=json_compatible_response)
 
     @router.get("/{slug}", response_model=Recipe)
     def get_one(self, slug: str):
diff --git a/mealie/routes/unit_and_foods/foods.py b/mealie/routes/unit_and_foods/foods.py
index a5b4c88d731..43f0cf7a335 100644
--- a/mealie/routes/unit_and_foods/foods.py
+++ b/mealie/routes/unit_and_foods/foods.py
@@ -7,8 +7,14 @@
 from mealie.routes._base.controller import controller
 from mealie.routes._base.mixins import HttpRepo
 from mealie.schema import mapper
-from mealie.schema.query import GetAll
-from mealie.schema.recipe.recipe_ingredient import CreateIngredientFood, IngredientFood, MergeFood, SaveIngredientFood
+from mealie.schema.recipe.recipe_ingredient import (
+    CreateIngredientFood,
+    IngredientFood,
+    IngredientFoodPagination,
+    MergeFood,
+    SaveIngredientFood,
+)
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.schema.response.responses import SuccessResponse
 
 router = APIRouter(prefix="/foods", tags=["Recipes: Foods"])
@@ -37,9 +43,15 @@ def merge_one(self, data: MergeFood):
             self.deps.logger.error(e)
             raise HTTPException(500, "Failed to merge foods") from e
 
-    @router.get("", response_model=list[IngredientFood])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, order_by=q.order_by, order_descending=q.order_descending)
+    @router.get("", response_model=IngredientFoodPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=IngredientFood,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=IngredientFood, status_code=201)
     def create_one(self, data: CreateIngredientFood):
diff --git a/mealie/routes/unit_and_foods/units.py b/mealie/routes/unit_and_foods/units.py
index 7314ea46438..95c780ffaf6 100644
--- a/mealie/routes/unit_and_foods/units.py
+++ b/mealie/routes/unit_and_foods/units.py
@@ -7,8 +7,14 @@
 from mealie.routes._base.controller import controller
 from mealie.routes._base.mixins import HttpRepo
 from mealie.schema import mapper
-from mealie.schema.query import GetAll
-from mealie.schema.recipe.recipe_ingredient import CreateIngredientUnit, IngredientUnit, MergeUnit, SaveIngredientUnit
+from mealie.schema.recipe.recipe_ingredient import (
+    CreateIngredientUnit,
+    IngredientUnit,
+    IngredientUnitPagination,
+    MergeUnit,
+    SaveIngredientUnit,
+)
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.schema.response.responses import SuccessResponse
 
 router = APIRouter(prefix="/units", tags=["Recipes: Units"])
@@ -37,9 +43,15 @@ def merge_one(self, data: MergeUnit):
             self.deps.logger.error(e)
             raise HTTPException(500, "Failed to merge units") from e
 
-    @router.get("", response_model=list[IngredientUnit])
-    def get_all(self, q: GetAll = Depends(GetAll)):
-        return self.repo.get_all(start=q.start, limit=q.limit, order_by=q.order_by, order_descending=q.order_descending)
+    @router.get("", response_model=IngredientUnitPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repo.page_all(
+            pagination=q,
+            override=IngredientUnit,
+        )
+
+        response.set_pagination_guides(router.url_path_for("get_all"), q.dict())
+        return response
 
     @router.post("", response_model=IngredientUnit, status_code=201)
     def create_one(self, data: CreateIngredientUnit):
diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py
index d68d8c24e31..1a0560b61ba 100644
--- a/mealie/routes/users/crud.py
+++ b/mealie/routes/users/crud.py
@@ -1,4 +1,4 @@
-from fastapi import HTTPException, status
+from fastapi import Depends, HTTPException, status
 from pydantic import UUID4
 
 from mealie.core.security import hash_password, verify_password
@@ -8,7 +8,9 @@
 from mealie.routes._base.routers import AdminAPIRouter, UserAPIRouter
 from mealie.routes.users._helpers import assert_user_change_allowed
 from mealie.schema.response import ErrorResponse, SuccessResponse
+from mealie.schema.response.pagination import PaginationQuery
 from mealie.schema.user import ChangePassword, UserBase, UserIn, UserOut
+from mealie.schema.user.user import UserPagination
 
 user_router = UserAPIRouter(prefix="/users", tags=["Users: CRUD"])
 admin_router = AdminAPIRouter(prefix="/users", tags=["Users: Admin CRUD"])
@@ -20,9 +22,15 @@ class AdminUserController(BaseAdminController):
     def mixins(self) -> HttpRepo:
         return HttpRepo[UserIn, UserOut, UserBase](self.repos.users, self.deps.logger)
 
-    @admin_router.get("", response_model=list[UserOut])
-    def get_all_users(self):
-        return self.repos.users.get_all()
+    @admin_router.get("", response_model=UserPagination)
+    def get_all(self, q: PaginationQuery = Depends(PaginationQuery)):
+        response = self.repos.users.page_all(
+            pagination=q,
+            override=UserOut,
+        )
+
+        response.set_pagination_guides(admin_router.url_path_for("get_all"), q.dict())
+        return response
 
     @admin_router.post("", response_model=UserOut, status_code=201)
     def create_user(self, new_user: UserIn):
diff --git a/mealie/schema/cookbook/cookbook.py b/mealie/schema/cookbook/cookbook.py
index 63a032fdcec..d16759c1923 100644
--- a/mealie/schema/cookbook/cookbook.py
+++ b/mealie/schema/cookbook/cookbook.py
@@ -3,6 +3,7 @@
 
 from mealie.schema._mealie import MealieModel
 from mealie.schema.recipe.recipe import RecipeSummary, RecipeTool
+from mealie.schema.response.pagination import PaginationBase
 
 from ..recipe.recipe_category import CategoryBase, TagBase
 
@@ -51,6 +52,10 @@ class Config:
         orm_mode = True
 
 
+class CookBookPagination(PaginationBase):
+    data: list[ReadCookBook]
+
+
 class RecipeCookBook(ReadCookBook):
     group_id: UUID4
     recipes: list[RecipeSummary]
diff --git a/mealie/schema/group/group_events.py b/mealie/schema/group/group_events.py
index d5f78bd4151..ba4c45303df 100644
--- a/mealie/schema/group/group_events.py
+++ b/mealie/schema/group/group_events.py
@@ -1,6 +1,7 @@
 from pydantic import UUID4, NoneStr
 
 from mealie.schema._mealie import MealieModel
+from mealie.schema.response.pagination import PaginationBase
 
 # =============================================================================
 # Group Events Notifier Options
@@ -83,6 +84,10 @@ class Config:
         orm_mode = True
 
 
+class GroupEventPagination(PaginationBase):
+    data: list[GroupEventNotifierOut]
+
+
 class GroupEventNotifierPrivate(GroupEventNotifierOut):
     apprise_url: str
 
diff --git a/mealie/schema/group/group_shopping_list.py b/mealie/schema/group/group_shopping_list.py
index 82058f35713..96c2fd37d7e 100644
--- a/mealie/schema/group/group_shopping_list.py
+++ b/mealie/schema/group/group_shopping_list.py
@@ -1,11 +1,12 @@
 from __future__ import annotations
 
-from typing import Optional
+from typing import Optional, Union
 
 from pydantic import UUID4
 
 from mealie.schema._mealie import MealieModel
 from mealie.schema.recipe.recipe_ingredient import IngredientFood, IngredientUnit
+from mealie.schema.response.pagination import PaginationBase
 
 
 class ShoppingListItemRecipeRef(MealieModel):
@@ -45,7 +46,7 @@ class ShoppingListItemUpdate(ShoppingListItemCreate):
 
 class ShoppingListItemOut(ShoppingListItemUpdate):
     label: Optional[MultiPurposeLabelSummary]
-    recipe_references: list[ShoppingListItemRecipeRefOut] = []
+    recipe_references: list[Union[ShoppingListItemRecipeRef, ShoppingListItemRecipeRefOut]] = []
 
     class Config:
         orm_mode = True
@@ -77,6 +78,10 @@ class Config:
         orm_mode = True
 
 
+class ShoppingListPagination(PaginationBase):
+    data: list[ShoppingListSummary]
+
+
 class ShoppingListUpdate(ShoppingListSummary):
     list_items: list[ShoppingListItemOut] = []
 
diff --git a/mealie/schema/group/webhook.py b/mealie/schema/group/webhook.py
index d9847651ab2..efebd8f9471 100644
--- a/mealie/schema/group/webhook.py
+++ b/mealie/schema/group/webhook.py
@@ -7,6 +7,7 @@
 from pydantic.datetime_parse import parse_datetime
 
 from mealie.schema._mealie import MealieModel
+from mealie.schema.response.pagination import PaginationBase
 
 
 class WebhookType(str, enum.Enum):
@@ -57,3 +58,7 @@ class ReadWebhook(SaveWebhook):
 
     class Config:
         orm_mode = True
+
+
+class WebhookPagination(PaginationBase):
+    data: list[ReadWebhook]
diff --git a/mealie/schema/labels/multi_purpose_label.py b/mealie/schema/labels/multi_purpose_label.py
index fee425ec9b2..45fc2e69833 100644
--- a/mealie/schema/labels/multi_purpose_label.py
+++ b/mealie/schema/labels/multi_purpose_label.py
@@ -3,6 +3,7 @@
 from pydantic import UUID4
 
 from mealie.schema._mealie import MealieModel
+from mealie.schema.response.pagination import PaginationBase
 
 
 class MultiPurposeLabelCreate(MealieModel):
@@ -25,6 +26,10 @@ class Config:
         orm_mode = True
 
 
+class MultiPurposeLabelPagination(PaginationBase):
+    data: list[MultiPurposeLabelSummary]
+
+
 class MultiPurposeLabelOut(MultiPurposeLabelUpdate):
     # shopping_list_items: list[ShoppingListItemOut] = []
     # foods: list[IngredientFood] = []
diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py
index 829d7c13f78..e4276866c73 100644
--- a/mealie/schema/meal_plan/plan_rules.py
+++ b/mealie/schema/meal_plan/plan_rules.py
@@ -4,6 +4,7 @@
 from pydantic import UUID4
 
 from mealie.schema._mealie import MealieModel
+from mealie.schema.response.pagination import PaginationBase
 
 
 class Category(MealieModel):
@@ -63,3 +64,7 @@ class PlanRulesOut(PlanRulesSave):
 
     class Config:
         orm_mode = True
+
+
+class PlanRulesPagination(PaginationBase):
+    data: list[PlanRulesOut]
diff --git a/mealie/schema/query.py b/mealie/schema/query.py
deleted file mode 100644
index d0342e5c588..00000000000
--- a/mealie/schema/query.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from typing import Optional
-
-from mealie.schema._mealie import MealieModel
-
-
-class GetAll(MealieModel):
-    start: int = 0
-    limit: int = 999
-    order_by: Optional[str]
-    order_descending: Optional[bool] = True
diff --git a/mealie/schema/recipe/recipe.py b/mealie/schema/recipe/recipe.py
index 2f76e5bd398..f1d3d3c93a2 100644
--- a/mealie/schema/recipe/recipe.py
+++ b/mealie/schema/recipe/recipe.py
@@ -12,6 +12,7 @@
 from mealie.core.config import get_app_dirs
 from mealie.db.models.recipe.recipe import RecipeModel
 from mealie.schema._mealie import MealieModel
+from mealie.schema.response.pagination import PaginationBase, PaginationQuery
 
 from .recipe_asset import RecipeAsset
 from .recipe_comments import RecipeCommentOut
@@ -32,15 +33,27 @@ class Config:
         orm_mode = True
 
 
+class RecipeTagPagination(PaginationBase):
+    data: list[RecipeTag]
+
+
 class RecipeCategory(RecipeTag):
     pass
 
 
+class RecipeCategoryPagination(PaginationBase):
+    data: list[RecipeCategory]
+
+
 class RecipeTool(RecipeTag):
     id: UUID4
     on_hand: bool = False
 
 
+class RecipeToolPagination(PaginationBase):
+    data: list[RecipeTool]
+
+
 class CreateRecipeBulk(BaseModel):
     url: str
     categories: list[RecipeCategory] = None
@@ -111,6 +124,14 @@ def validate_user_id(user_id: Any):
         return user_id
 
 
+class RecipePaginationQuery(PaginationQuery):
+    load_food: bool = False
+
+
+class RecipePagination(PaginationBase):
+    data: list[RecipeSummary]
+
+
 class Recipe(RecipeSummary):
     recipe_ingredient: list[RecipeIngredient] = []
     recipe_instructions: Optional[list[RecipeStep]] = []
@@ -163,7 +184,7 @@ def getter_dict(cls, name_orm: RecipeModel):
             }
 
     @validator("slug", always=True, pre=True, allow_reuse=True)
-    def validate_slug(slug: str, values):
+    def validate_slug(slug: str, values):  # type: ignore
         if not values.get("name"):
             return slug
 
diff --git a/mealie/schema/recipe/recipe_comments.py b/mealie/schema/recipe/recipe_comments.py
index 996fa4c6f4c..2cb8864363d 100644
--- a/mealie/schema/recipe/recipe_comments.py
+++ b/mealie/schema/recipe/recipe_comments.py
@@ -4,6 +4,7 @@
 from pydantic import UUID4
 
 from mealie.schema._mealie import MealieModel
+from mealie.schema.response.pagination import PaginationBase
 
 
 class UserBase(MealieModel):
@@ -39,3 +40,7 @@ class RecipeCommentOut(RecipeCommentCreate):
 
     class Config:
         orm_mode = True
+
+
+class RecipeCommentPagination(PaginationBase):
+    data: list[RecipeCommentOut]
diff --git a/mealie/schema/recipe/recipe_ingredient.py b/mealie/schema/recipe/recipe_ingredient.py
index e779d9cbd90..9c0b30d09dc 100644
--- a/mealie/schema/recipe/recipe_ingredient.py
+++ b/mealie/schema/recipe/recipe_ingredient.py
@@ -8,6 +8,7 @@
 
 from mealie.schema._mealie import MealieModel
 from mealie.schema._mealie.types import NoneFloat
+from mealie.schema.response.pagination import PaginationBase
 
 
 class UnitFoodBase(MealieModel):
@@ -31,6 +32,10 @@ class Config:
         orm_mode = True
 
 
+class IngredientFoodPagination(PaginationBase):
+    data: list[IngredientFood]
+
+
 class CreateIngredientUnit(UnitFoodBase):
     fraction: bool = True
     abbreviation: str = ""
@@ -48,6 +53,10 @@ class Config:
         orm_mode = True
 
 
+class IngredientUnitPagination(PaginationBase):
+    data: list[IngredientUnit]
+
+
 class RecipeIngredient(MealieModel):
     title: Optional[str]
     note: Optional[str]
diff --git a/mealie/schema/response/pagination.py b/mealie/schema/response/pagination.py
index a8f5c9dbf17..29ded9cb010 100644
--- a/mealie/schema/response/pagination.py
+++ b/mealie/schema/response/pagination.py
@@ -1,9 +1,12 @@
 import enum
-from typing import Generic, TypeVar
+from typing import Any, Generic, Optional, TypeVar
+from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
 
 from pydantic import BaseModel
 from pydantic.generics import GenericModel
 
+from mealie.schema._mealie import MealieModel
+
 DataT = TypeVar("DataT", bound=BaseModel)
 
 
@@ -12,11 +15,11 @@ class OrderDirection(str, enum.Enum):
     desc = "desc"
 
 
-class PaginationQuery(BaseModel):
+class PaginationQuery(MealieModel):
     page: int = 1
+    per_page: int = 50
     order_by: str = "created_at"
     order_direction: OrderDirection = OrderDirection.desc
-    per_page: int = 50
 
 
 class PaginationBase(GenericModel, Generic[DataT]):
@@ -25,3 +28,44 @@ class PaginationBase(GenericModel, Generic[DataT]):
     total: int = 0
     total_pages: int = 0
     data: list[DataT]
+    next: Optional[str]
+    previous: Optional[str]
+
+    def _set_next(self, route: str, query_params: dict[str, Any]) -> None:
+        if self.page >= self.total_pages:
+            self.next = None
+            return
+
+        # combine params with base route
+        query_params.update({"page": self.page + 1})
+        self.next = PaginationBase.merge_query_parameters(route, query_params)
+
+    def _set_prev(self, route: str, query_params: dict[str, Any]) -> None:
+        if self.page <= 1:
+            self.previous = None
+            return
+
+        # combine params with base route
+        query_params.update({"page": self.page - 1})
+        self.previous = PaginationBase.merge_query_parameters(route, query_params)
+
+    def set_pagination_guides(self, route: str, query_params: Optional[dict[str, Any]]) -> None:
+        if not query_params:
+            query_params = {}
+
+        # sanitize user input
+        if self.page < 1:
+            self.page = 1
+
+        self._set_next(route, query_params)
+        self._set_prev(route, query_params)
+
+    @staticmethod
+    def merge_query_parameters(url: str, params: dict[str, Any]):
+        scheme, netloc, path, query_string, fragment = urlsplit(url)
+
+        query_params = parse_qs(query_string)
+        query_params.update(params)
+        new_query_string = urlencode(query_params, doseq=True)
+
+        return urlunsplit((scheme, netloc, path, new_query_string, fragment))
diff --git a/mealie/schema/server/tasks.py b/mealie/schema/server/tasks.py
index 9ea4e5ce51a..a7c3cac657d 100644
--- a/mealie/schema/server/tasks.py
+++ b/mealie/schema/server/tasks.py
@@ -5,6 +5,7 @@
 from pydantic import Field
 
 from mealie.schema._mealie import MealieModel
+from mealie.schema.response.pagination import PaginationBase
 
 
 class ServerTaskNames(str, enum.Enum):
@@ -45,3 +46,7 @@ class ServerTask(ServerTaskCreate):
 
     class Config:
         orm_mode = True
+
+
+class ServerTaskPagination(PaginationBase):
+    data: list[ServerTask]
diff --git a/mealie/schema/user/user.py b/mealie/schema/user/user.py
index a26dc9960cb..c7e2a5a2b8a 100644
--- a/mealie/schema/user/user.py
+++ b/mealie/schema/user/user.py
@@ -11,6 +11,7 @@
 from mealie.schema._mealie import MealieModel
 from mealie.schema.group.group_preferences import ReadGroupPreferences
 from mealie.schema.recipe import RecipeSummary
+from mealie.schema.response.pagination import PaginationBase
 
 from ..recipe import CategoryBase
 
@@ -111,6 +112,10 @@ def getter_dict(cls, ormModel: User):
             }
 
 
+class UserPagination(PaginationBase):
+    data: list[UserOut]
+
+
 class UserFavorites(UserBase):
     favorite_recipes: list[RecipeSummary] = []  # type: ignore
 
@@ -178,6 +183,10 @@ def exports(self) -> Path:
         return GroupInDB.get_export_directory(self.id)
 
 
+class GroupPagination(PaginationBase):
+    data: list[GroupInDB]
+
+
 class LongLiveTokenInDB(CreateToken):
     id: int
     user: PrivateUser
diff --git a/tests/integration_tests/admin_tests/test_admin_background_tasks.py b/tests/integration_tests/admin_tests/test_admin_background_tasks.py
index ae0e813b1a0..06ce8bfadba 100644
--- a/tests/integration_tests/admin_tests/test_admin_background_tasks.py
+++ b/tests/integration_tests/admin_tests/test_admin_background_tasks.py
@@ -10,13 +10,13 @@ class Routes:
 
 def test_admin_server_tasks_test_and_get(api_client: TestClient, admin_user: TestUser):
     # Bootstrap Timer
-    BackgroundExecutor.sleep_time = 0.1
+    BackgroundExecutor.sleep_time = 1
 
     response = api_client.post(Routes.base, headers=admin_user.token)
     assert response.status_code == 201
 
     response = api_client.get(Routes.base, headers=admin_user.token)
-    as_dict = response.json()
+    as_dict = response.json()["data"]
 
     assert len(as_dict) == 1
 
diff --git a/tests/integration_tests/user_group_tests/test_group_cookbooks.py b/tests/integration_tests/user_group_tests/test_group_cookbooks.py
index 53019c3df79..6c5b6751a5b 100644
--- a/tests/integration_tests/user_group_tests/test_group_cookbooks.py
+++ b/tests/integration_tests/user_group_tests/test_group_cookbooks.py
@@ -111,7 +111,7 @@ def test_update_cookbooks_many(api_client: TestClient, unique_user: TestUser, co
 
     known_ids = [x.id for x in cookbooks]
 
-    server_ids = [x["id"] for x in response.json()]
+    server_ids = [x["id"] for x in response.json()["data"]]
 
     for know in known_ids:  # Hacky check, because other tests don't cleanup after themselves :(
         assert str(know) in server_ids
diff --git a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py
index a45c336ad66..6499c2a6536 100644
--- a/tests/integration_tests/user_group_tests/test_group_shopping_lists.py
+++ b/tests/integration_tests/user_group_tests/test_group_shopping_lists.py
@@ -22,7 +22,7 @@ def add_recipe(item_id: str, recipe_id: str) -> str:
 def test_shopping_lists_get_all(api_client: TestClient, unique_user: TestUser, shopping_lists: list[ShoppingListOut]):
     all_lists = api_client.get(Routes.base, headers=unique_user.token)
     assert all_lists.status_code == 200
-    all_lists = all_lists.json()
+    all_lists = all_lists.json()["data"]
 
     assert len(all_lists) == len(shopping_lists)
 
diff --git a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py
index aefc18d54f3..651c695fc83 100644
--- a/tests/integration_tests/user_recipe_tests/test_recipe_owner.py
+++ b/tests/integration_tests/user_recipe_tests/test_recipe_owner.py
@@ -44,7 +44,7 @@ def test_get_all_only_includes_group_recipes(api_client: TestClient, unique_user
 
     assert response.status_code == 200
 
-    recipes = response.json()
+    recipes = response.json()["data"]
 
     assert len(recipes) == 5
 
diff --git a/tests/multitenant_tests/test_multitenant_cases.py b/tests/multitenant_tests/test_multitenant_cases.py
index 7d7dd64019a..8a3388ecaab 100644
--- a/tests/multitenant_tests/test_multitenant_cases.py
+++ b/tests/multitenant_tests/test_multitenant_cases.py
@@ -47,7 +47,7 @@ def test_multitenant_cases_get_all(
             response = test_case.get_all(token)
             assert response.status_code == 200
 
-            data = response.json()
+            data = response.json()["data"]
 
             assert len(data) == len(item_ids)
 
@@ -84,7 +84,7 @@ def test_multitenant_cases_same_named_resources(
             response = test_case.get_all(token)
             assert response.status_code == 200
 
-            data = response.json()
+            data = response.json()["data"]
 
             assert len(data) == len(item_ids)
 
diff --git a/tests/unit_tests/repository_tests/test_pagination.py b/tests/unit_tests/repository_tests/test_pagination.py
index 7661055815f..a3192603271 100644
--- a/tests/unit_tests/repository_tests/test_pagination.py
+++ b/tests/unit_tests/repository_tests/test_pagination.py
@@ -21,7 +21,7 @@ def test_repository_pagination(database: AllRepositories, unique_user: TestUser)
     seen = []
 
     for _ in range(10):
-        results = foods_repo.pagination(query)
+        results = foods_repo.page_all(query)
 
         assert len(results.data) == 10
 
@@ -32,7 +32,7 @@ def test_repository_pagination(database: AllRepositories, unique_user: TestUser)
 
         query.page += 1
 
-    results = foods_repo.pagination(query)
+    results = foods_repo.page_all(query)
 
     for result in results.data:
         assert result.id not in seen


Code review comments:
diff_hunk: @@ -240,11 +243,31 @@ def pagination(self, pagination: PaginationQuery, override=None) -> PaginationBa
 
         fltr = self._filter_builder()
         q = q.filter_by(**fltr)
-
         count = q.count()
 
+        # interpret -1 as "get_all"
+        if pagination.per_page == -1:
+            pagination.per_page = count
+
+        try:
+            total_pages = ceil(count / pagination.per_page)
+
+        except ZeroDivisionError:
+            total_pages = 0
+
+        # interpret -1 as "last page"
+        if pagination.page == -1:
+            pagination.page = total_pages
+
+        # failsafe for user input error
+        if pagination.page < 1:
+            pagination.page = 1
+
         if pagination.order_by:
             if order_attr := getattr(self.model, pagination.order_by, None):
+                # queries handle uppercase and lowercase differently, which is undesirable
+                order_attr = func.lower(order_attr)
+
path: mealie/repos/repository_generic.py
text: @user1:
I think this is why your PG Tests are failing. PG Stores UUIDs as Integers or Bytes ( I can't remember), so when you try to use the lower() SQL function it's causing an error.  I think SQLite just stores UUIDs as strings so it works as expected. 

@author:
Ahh okay, that makes sense. I'll do a type check on that then (which I probably should've done anyway). Thanks!