Courses

Vue Laravel 12 Starter Kit: CRUD Project

Table Pagination with ShadCN Pagination Component

Summary of this lesson:
- Implementing Laravel pagination
- Creating a TypeScript interface for paginated response data
- Building reusable TablePagination component with Shadcn Vue Pagination component
- Adding advanced pagination with numbered page navigation

In this lesson, we will add pagination to our table. It won't be an easy one-minute process.


Modifying Controller for Pagination

app/Http/Controllers/TaskController.php

// ...
 
public function index()
{
return Inertia::render('Tasks/Index', [
'tasks' => Task::all()
'tasks' => Task::paginate(5)
]);
}
 
// ...

Now, our Task List page will stop working because of a TypeScript structure mismatch. We return paginate() from Controller, which is no longer the array of the Task type. Let's fix this.


Creating Paginated Response Type

We return tasks from the Controller not as a list but as a paginated object.

So, we need to create a specific new type for it, corresponding to the structure returned by Laravel pagination.

resources/js/types/index.d.ts

// ...
 
export interface PaginatedResponse<T = Task | null> {
current_page: number;
data: T[];
first_page_url: string;
from: number;
last_page: number;
last_page_url: string;
links: {
url: string | null;
label: string;
active: boolean;
}[];
next_page_url: string | null;
path: string;
per_page: number;
prev_page_url: string | null;
to: number;
total: number;
}

Accepting Paginated Response in Vue

In three places, we need to change the type of data we get.

  • We import our new PaginatedResponse type
  • We use it in the export function instead of the old list type
  • We change tasks.map() to tasks.data.map() because Laravel returns the data in tasks.data now

resources/js/pages/Tasks/Index.vue

<script>
import { type BreadcrumbItem, Task } from '@/types';
import { type BreadcrumbItem, PaginatedResponse, Task } from '@/types';
 
// ...
 
interface Props {
tasks: Task[];
tasks: PaginatedResponse<Task>;
}
// ...
</script>
 
<template>
<TableRow v-for="task in tasks" :key="task.id">
<TableRow v-for="task in tasks.data" :key="task.id">
// ...
</TableRow>
</template>

Ok, now our page should load successfully again.

But wait... we still don't have pagination?


Pagination Component from Shadcn

Shadcn has a component of Pagination.

So, we install it with a regular npx command.

npx shadcn-vue@latest add pagination

We could use it right away, but I suggest considering the future when we will use the same pagination on other pages. So, we will build our own reusable pagination component suitable for our design.


Creating a "Simple" Pagination Component

Shadcn Pagination gives us the components like <Pagination> and others. Here's an example of Usage from the docs:

Let's use those <Pagination>, <PaginationContent>, <PaginationItem> and other components in our own new Vue component we'll call <Pagination>, custom to our own design.

For now, we will add only "Next" and "Previous" links.

resources/js/components/Pagination.vue

<script setup lang="ts">
import { Pagination, PaginationList, PaginationNext, PaginationPrev } from '@/components/ui/pagination';
import type { PaginatedResponse } from '@/types';
import { router } from '@inertiajs/vue3';
 
interface Props {
resource: PaginatedResponse;
}
 
const props = withDefaults(defineProps<Props>(), {
resource: null,
});
</script>
 
<template>
<Pagination :items-per-page="10" :total="100" :sibling-count="1" show-edges :default-page="2" class="mx-auto">
<PaginationList class="flex items-center gap-1">
<div v-if="props.resource.last_page === 1">
<div class="mt-4 text-center text-gray-500">No more items to show.</div>
</div>
<div v-if="props.resource.last_page !== 1">
<PaginationPrev v-on:click="() => router.visit(props.resource?.prev_page_url)" v-if="props.resource.prev_page_url" />
 
<PaginationNext v-on:click="() => router.visit(props.resource?.next_page_url)" v-if="props.resource.next_page_url" />
</div>
</PaginationList>
</Pagination>
</template>

Here's the visual result:

If we click "Next", the URL changes, and we see the "Previous" button instead.

Now, if we want to build pagination with numbers, it's more complicated.


Pagination with Numbers

Shadcn is a CSS component library. It's for the presentation layer. It doesn't contain the logic for calculating and showing the page numbers. We need to handle this logic ourselves in custom JavaScript.

It's a pretty complex logic. Luckily, Vue version comes with some helping functions like items-per-page, total, sibling-count, show-edges, and default-page.

From here, we mixed their logic with our own custom buttons and links:

  • Current page to mark the active page
  • Total pages to determine the logic
  • Path to generate the links for (e.g. /tasks)
  • Page query to append to the path (e.g., ?page=)
  • Switching pages with router.visit()

resources/js/components/Pagination.vue

<script setup lang="ts">
import { Button } from '@/components/ui/button';
import {
Pagination,
PaginationEllipsis,
PaginationFirst,
PaginationLast,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
} from '@/components/ui/pagination';
import type { PaginatedResponse } from '@/types';
import { router } from '@inertiajs/vue3';
 
interface Props {
resource: PaginatedResponse;
}
 
const props = withDefaults(defineProps<Props>(), {
resource: null,
});
</script>
 
<template>
<Pagination
:items-per-page="props.resource.per_page"
:total="props.resource.total"
:sibling-count="1"
show-edges
:default-page="props.resource.current_page"
class="mx-auto"
>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<div v-if="props.resource.last_page === 1">
<div class="mt-4 text-center text-gray-500">No more items to show.</div>
</div>
<div v-if="props.resource.last_page !== 1">
<PaginationFirst v-on:click="() => router.visit(props.resource.first_page_url)" />
<PaginationPrev v-on:click="() => router.visit(props.resource.prev_page_url)" v-if="props.resource.prev_page_url" />
 
<template v-for="(item, index) in items">
<PaginationListItem v-if="item.type === 'page'" :key="index" :value="item.value" as-child>
<Button
class="h-10 w-10 p-0"
:variant="item.value === props.resource.current_page ? 'default' : 'outline'"
v-on:click="() => router.visit(props.resource.links[index+1].url)"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
 
<PaginationNext v-on:click="() => router.visit(props.resource.next_page_url)" v-if="props.resource.next_page_url" />
<PaginationLast v-on:click="() => router.visit(props.resource.last_page_url)" />
</div>
</PaginationList>
</Pagination>
</template>

Now we have a proper numbered pagination:

Also, if we change the pagination to 10 records per page, we show the text on the bottom that there are no more records:


Here's the GitHub commit for this lesson.

Previous: Edit Form and Breadcrumbs
avatar

MIssing call "<Pagination :resource="tasks" />" in pages/Tasks/Index.vue.

avatar

Thank you for reporting this! I've fixed the tutorial

avatar
You can use Markdown
avatar
You can use Markdown