diff --git a/django-backend/soroscan/middleware.py b/django-backend/soroscan/middleware.py
index 46e07fda0..84226b27e 100644
--- a/django-backend/soroscan/middleware.py
+++ b/django-backend/soroscan/middleware.py
@@ -151,4 +151,4 @@ def __call__(self, request):
response["Sunset"] = config.get("sunset", "")
response["Link"] = f'<{config.get("replacement", "")}>; rel="replacement"'
break
- return response
\ No newline at end of file
+ return response
diff --git a/django-backend/soroscan/settings_test.py b/django-backend/soroscan/settings_test.py
index 16aa4462f..81824243e 100644
--- a/django-backend/soroscan/settings_test.py
+++ b/django-backend/soroscan/settings_test.py
@@ -40,6 +40,7 @@
"corsheaders.middleware.CorsMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"soroscan.middleware.RequestIdMiddleware",
+ "soroscan.middleware.Json404Middleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
diff --git a/soroscan-frontend/__tests__/pagination.test.tsx b/soroscan-frontend/__tests__/pagination.test.tsx
new file mode 100644
index 000000000..92dc033b1
--- /dev/null
+++ b/soroscan-frontend/__tests__/pagination.test.tsx
@@ -0,0 +1,135 @@
+import { fireEvent, render, screen } from "@testing-library/react"
+import "@testing-library/jest-dom"
+import { Pagination } from "@/src/components/ui/Pagination"
+
+describe("Pagination", () => {
+ it("renders current and total page count", () => {
+ render(
+
+ )
+
+ expect(screen.getByText("Page 3 of 12")).toBeInTheDocument()
+ })
+
+ it("disables First and Previous on the first page", () => {
+ render(
+
+ )
+
+ expect(screen.getByRole("button", { name: "First page" })).toBeDisabled()
+ expect(screen.getByRole("button", { name: "Previous page" })).toBeDisabled()
+ })
+
+ it("disables Next and Last on the final page", () => {
+ render(
+
+ )
+
+ expect(screen.getByRole("button", { name: "Next page" })).toBeDisabled()
+ expect(screen.getByRole("button", { name: "Last page" })).toBeDisabled()
+ })
+
+ it("calls callbacks for first, previous, next, and last actions", () => {
+ const onPageChange = jest.fn()
+
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole("button", { name: "First page" }))
+ fireEvent.click(screen.getByRole("button", { name: "Previous page" }))
+ fireEvent.click(screen.getByRole("button", { name: "Next page" }))
+ fireEvent.click(screen.getByRole("button", { name: "Last page" }))
+
+ expect(onPageChange).toHaveBeenNthCalledWith(1, 1)
+ expect(onPageChange).toHaveBeenNthCalledWith(2, 4)
+ expect(onPageChange).toHaveBeenNthCalledWith(3, 6)
+ expect(onPageChange).toHaveBeenNthCalledWith(4, 10)
+ })
+
+ it("supports direct page number jumping", () => {
+ const onPageChange = jest.fn()
+
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole("button", { name: "Go to page 4" }))
+ expect(onPageChange).toHaveBeenCalledWith(4)
+ })
+
+ it("marks active page with aria-current=page", () => {
+ render(
+
+ )
+
+ expect(screen.getByRole("button", { name: "Go to page 5" })).toHaveAttribute("aria-current", "page")
+ })
+
+ it("renders ellipsis for large page ranges", () => {
+ render(
+
+ )
+
+ expect(screen.getAllByText("...").length).toBeGreaterThan(0)
+ })
+
+ it("calls onPageSizeChange when selecting page size", () => {
+ const onPageSizeChange = jest.fn()
+
+ render(
+
+ )
+
+ fireEvent.change(screen.getByLabelText("Page size"), { target: { value: "20" } })
+ expect(onPageSizeChange).toHaveBeenCalledWith(20)
+ })
+})
diff --git a/soroscan-frontend/src/components/ui/Pagination.tsx b/soroscan-frontend/src/components/ui/Pagination.tsx
new file mode 100644
index 000000000..7ac3f78e5
--- /dev/null
+++ b/soroscan-frontend/src/components/ui/Pagination.tsx
@@ -0,0 +1,193 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+type PaginationToken = number | "ellipsis"
+
+export interface PaginationProps {
+ totalItems: number
+ pageSize: number
+ currentPage: number
+ onPageChange: (page: number) => void
+ onPageSizeChange: (pageSize: number) => void
+ pageSizeOptions?: number[]
+ maxVisiblePages?: number
+ className?: string
+}
+
+function clampPage(page: number, totalPages: number): number {
+ if (totalPages <= 0) {
+ return 1
+ }
+
+ if (page < 1) {
+ return 1
+ }
+
+ if (page > totalPages) {
+ return totalPages
+ }
+
+ return page
+}
+
+function getPaginationRange(currentPage: number, totalPages: number, maxVisiblePages: number): PaginationToken[] {
+ if (totalPages <= maxVisiblePages) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1)
+ }
+
+ const safeMaxVisible = Math.max(5, maxVisiblePages)
+ const siblingCount = Math.max(1, Math.floor((safeMaxVisible - 3) / 2))
+ const leftSiblingIndex = Math.max(currentPage - siblingCount, 1)
+ const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages)
+
+ const showLeftEllipsis = leftSiblingIndex > 2
+ const showRightEllipsis = rightSiblingIndex < totalPages - 1
+
+ if (!showLeftEllipsis && showRightEllipsis) {
+ const leftItemCount = safeMaxVisible - 2
+ const leftRange = Array.from({ length: leftItemCount }, (_, i) => i + 1)
+ return [...leftRange, "ellipsis", totalPages]
+ }
+
+ if (showLeftEllipsis && !showRightEllipsis) {
+ const rightItemCount = safeMaxVisible - 2
+ const start = totalPages - rightItemCount + 1
+ const rightRange = Array.from({ length: rightItemCount }, (_, i) => start + i)
+ return [1, "ellipsis", ...rightRange]
+ }
+
+ const middleRange = Array.from(
+ { length: rightSiblingIndex - leftSiblingIndex + 1 },
+ (_, i) => leftSiblingIndex + i
+ )
+
+ return [1, "ellipsis", ...middleRange, "ellipsis", totalPages]
+}
+
+export function Pagination({
+ totalItems,
+ pageSize,
+ currentPage,
+ onPageChange,
+ onPageSizeChange,
+ pageSizeOptions = [10, 20, 50],
+ maxVisiblePages = 5,
+ className,
+}: PaginationProps) {
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
+ const safeCurrentPage = clampPage(currentPage, totalPages)
+
+ const pageRange = React.useMemo(
+ () => getPaginationRange(safeCurrentPage, totalPages, maxVisiblePages),
+ [safeCurrentPage, totalPages, maxVisiblePages]
+ )
+
+ const isFirstPage = safeCurrentPage === 1
+ const isLastPage = safeCurrentPage === totalPages
+
+ return (
+
+ )
+}
+
+export { getPaginationRange }