Laravel and Vue pagination

paginate, simplePaginate, and cursorPaginate return different JSON. The Vue page can only render the links that object actually has.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-vue-pagination.md
Page a Laravel list in Vue with the links that method actually returns.
Do not use a duplicated published_at as the cursor bound.
Do not read last_page or total off simplePaginate.
Show the diff. Do not commit.

Next: Upload a file

On this page · 8 sections
  1. What each method returns
  2. The Vue footer
  3. A test that locks the shape
  4. Where this stops
  5. Recap
  6. FAQ
  7. Next steps
  8. Sources

You switch paginate to simplePaginate for speed and the footer shows “Page 2 of undefined”. Each Laravel paginator sends a different payload. Here is what each one returns and a Vue footer that works with all of them.

TL;DR

  • paginate counts (has total, last_page); simplePaginate and cursorPaginate do not.
  • cursorPaginate needs a unique order column: order by id.
  • Render Previous/Next from prev_page_url / next_page_url; they are null at the ends.
  • Add withQueryString() so filters survive the next page.

What you'll learn

  • What paginate, simplePaginate and cursorPaginate return
  • A Vue footer that works for all three
  • How to lock the shape with a test

Who it's for: Laravel + Vue developers with growing lists

Applies to: Laravel 13 · Inertia 3 · Vue 3 · Pest

The CRUD guide loads posts with paginate(10). That is the right call when the list is small enough to count. This page is the other two paginators, and the Vue markup that breaks when you swap them without reading the payload.

If you are still choosing Inertia, a separate SPA, or Nuxt, start with the stack guide. Sorting the same list is the table guide. Do not call Post::all() and paginate in the browser.

What each method returns#

MethodCount querytotal / last_pageURL
paginate(10)YesYes?page=2
simplePaginate(10)NoNo?page=2
cursorPaginate(10)NoNo?cursor=…

paginate(10) runs a count query. The JSON includes total, last_page, current_page, prev_page_url, and next_page_url. The CRUD page can say “Page 2 of 6” because those keys exist.

simplePaginate(10) skips the count. You get current_page and the previous and next URLs. There is no total and no last_page. Use it when the table is large and the footer only needs Previous and Next.

Gotcha: cursors need a unique order

cursorPaginate(10) also skips the count. Instead of ?page=2 it uses a cursor string and returns next_page_url and prev_page_url. There is no last_page. It needs a unique order column, or a unique combination of columns. Two rows with the same published_at do not give the cursor a stable bound, so order by id. Skip and repeat when rows are inserted or deleted between pages is the offset case, paginate and simplePaginate.

app/Http/Controllers/PostController.phpphp
$posts = Post::query()
    ->latest('id')
    ->cursorPaginate(10);

Swap cursorPaginate for simplePaginate when you want page numbers without a total. Keep paginate from the CRUD guide when the count is cheap and the UI shows it.

Read posts.data for the rows, the same as the CRUD page. Stop reading last_page unless you called paginate().

resources/js/pages/posts/Index.vuevue
<nav class="flex gap-4">
  <Link v-if="posts.prev_page_url" :href="posts.prev_page_url">Previous</Link>
  <Link v-if="posts.next_page_url" :href="posts.next_page_url">Next</Link>
</nav>

prev_page_url and next_page_url are null on the ends. The v-if is the whole empty state. A cursor page and a simple page can share this footer. A counted page can add the current page and last_page beside it.

Tip: keep the filter on page 2

withQueryString() still matters once the list has a search box. Without it, Next drops ?search= and the second page is the unfiltered table.

php
Post::query()
    ->when($search !== '', fn ($query) => $query->where('title', 'like', "%{$search}%"))
    ->latest('id')
    ->simplePaginate(10)
    ->withQueryString();

A test that locks the shape#

Fail fast if someone swaps the paginator

tests/Feature/PostsPaginationTest.phpphp
it('does not claim a last page it did not count', function () {
    Post::factory()->count(3)->create();

    $this->get('/posts')
        ->assertOk()
        ->assertInertia(fn ($page) => $page
            ->has('posts.data', 3)
            ->missing('posts.last_page')
            ->missing('posts.total')
        );
});

Point that test at whichever method you chose. missing('posts.total') fails if someone “helpfully” switches the controller back to paginate() while the Vue page was written for a simple paginator.

Where this stops#

Loading the next page by appending rows, instead of replacing them, is a partial reload. That is partial reloads. The form, the search box, and paginate(10) stay in the CRUD guide.

Recap#

  • Pick the paginator by whether you can afford (and show) a count.
  • One Previous/Next footer works for all three.
  • Order cursor pages by a unique column.
  • withQueryString() whenever there is a filter.

FAQ#

Why is last_page undefined in my Vue pagination?

You are using simplePaginate or cursorPaginate, which skip the count. Use paginate or drop last_page from the footer.

When should I use cursorPaginate in Laravel?

For large, frequently changing lists where offset pages skip or repeat rows. Order by a unique column such as id.

Why does page 2 lose my search filter?

Add ->withQueryString() to the paginator so the links keep ?search=.

Next steps#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.