Playing Next Lesson In
seconds

Let's Learn AdonisJS 7 #3.6

Pagination

In This Lesson

Learn pagination in AdonisJS. Split large datasets into pages to improve performance and user experience with Lucid paginate().

Created by
@tomgobich
Published

If you've ever been on a site that's had its data spread across multiple numbered pages, like page 1, 2, 3, etc., that's called pagination. Pagination is a way to split large lists of data into multiple paged chunks. By paging our data, we make it easier for the user to sift through and load more quickly, so it's a win-win on both counts.

Pagination can be a pain to do, but AdonisJS makes it a breeze. We can easily paginate our data using the query builder's paginate() method. It accepts two arguments: the current page of data we're on and the number of results we want per page. The page number starts at one and can increment from there.

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ view }: HttpContext) {
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =>
        creator.whereHas('profile', (profile) => profile.where('isPublic', true))
      )
      .preload('createdBy')
      .paginate(1, 12)

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

For example, above, we're grabbing the first page of our challenges, where each page consists of 12 challenges per page. Though the amount we want per page may be static, in most cases, these are fluid numbers often stored in the URL's query string. This allows the user to specify which page of data they're after. It also allows them to specify the number of items they'd like to see per page.

We could grab this off our query string using request.input(), but as we learned several lessons ago, we want to be defensive when accepting data. We also learned that the query string gets merged with our body when passed to the request's validate method. So, we can easily validate our query string data using a validator.

We'll jump into our challenge validation file and add a challengeIndexValidator. If you're going to have a lot of similar paginated pages in your application, you could also just do a paginationValidator if you'd prefer.

import vine from '@vinejs/vine'

export const challengeIndexValidator = vine.create({
  page: vine
    .number()
    .parse((value) => value || 1)
    .positive(),
  perPage: vine
    .number()
    .parse((value) => value || 12)
    .range([12, 100]),
})

export const challengeValidator = vine.create({
  text: vine.string().minLength(5).maxLength(255),
  points: vine.number().range([1, 100]),
})
Copied!
  • app
  • validators
  • challenge.ts

With this, we're stating both page and perPage should be numbers. Our page should be positive (> 0). Then, perPage should be between 12 and 100 to disallow users from requesting something like 10,000 in a page and bog things down. Something new we haven't yet discussed is the parse() method. This is run before any of our validations and allows us to parse and return the input value ourselves. Here, we're using it to set a default value if a page or perPage are omitted. You can find the full list of rules available on vine.number() in the VineJS number type docs.

Then, we can use this validator in our controller and use the validated page and perPage for our pagination.

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =>
        creator.whereHas('profile', (profile) => profile.where('isPublic', true))
      )
      .preload('createdBy')
      .paginate(page, perPage)

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Now, if we check out our page we'll notice we only see 12 challenges. However, we can update our URL to include a page and/or perPage query string to alter the page we're on and how many items we see per page.

/challenges?page=2&perPage=24

When we call the paginate() method, notice our return type is no longer Challenge[], an array of our Challenge model instances. Rather, it's of type ModelPaginatorContract<Challenge>. The ModelPaginator is a class that extends SimplePaginator that extends Array, meaning it is still iterable, hence why our loop to display the challenges isn't broken. However, it also contains properties and methods to inform and help us work with our paginator. You can find the full paginator API in the Lucid pagination docs.

SimplePaginator {
  // data
  rows: T[]

  // metadata
  perPage: 12,
  currentPage: 1,
  firstPage: 1,
  isEmpty: false,
  total: 37,
  hasTotal: true,
  lastPage: 4,
  hasMorePages: true,
  hasPages: true,

  // methods
  all(), // returns all rows
  getMeta(), // returns JSON metadata
  toJSON(), // return { meta: getMeta(), data: all() }
  queryString(values: { [key: string]: any }), // add qs to generated URLs
  baseUrl(url: string), // set base URL for pagination links
  getUrl(page: number), // returns URL for the page
  getNextPageUrl(), // returns URL for the next page
  getPreviousPageUrl(), // returns URL for the previous page
  getUrlsForRange(start: number, end: number), // returns array of URLs for range
}
Copied!

So, despite our challenges consisting of only 12 items for our first page of data, we can still get at the overall total with ease via challenges.total.

@layout()

  <div>
    @if (session.has('success'))
      <div class="alert alert-success">
        {{ session.get('success') }}
      </div>
    @endif

    <div class="hero">
      <h1>Challenges</h1>
  
      @include('partials/challenge/available_points')

      <div>
        Total Challenges: {{ challenges.total }}
      </div>
  
      <a href="{{ route('challenges.create') }}" class="button">Create a new challenge</a>
    </div>

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

Now, we need to allow our users to easily traverse from page to page. Let's start by adding our next and previous links using the getPreviousPageUrl() and getNextPageUrl(). Importantly, note that if there isn't a previous or next page, these will return null, so we'll want to wrap them in an if statement.

@layout()

  <div>
    {{-- ... --}}

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
    
    <div style="display: flex; gap: .25rem;">
      @if (challenges.currentPage > 1)
        <a href="{{ challenges.getPreviousPageUrl() }}">Prev</a>
      @endif

      @if (challenges.hasMorePages)
        <a href="{{ challenges.getNextPageUrl() }}">Next</a>
      @endif
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

So, if we're on page one, we won't see our previous link, and if we're on the last page, we won't see our next link. Additionally, if we check out the links generated here, they're pointing to /?page=2, which is our home page. We want this to instead be /challenges?page=2 to keep the user on this page. For that, we can use the baseUrl() method in our controller.

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =>
        creator.whereHas('profile', (profile) => profile.where('isPublic', true))
      )
      .preload('createdBy')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Now, all the URLs generated by our paginator will point to /challenges instead of just /. However, if we switch our perPage to 24, for example, notice neither our next nor previous links keep that. We can easily fix that using the queryString() method!

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const challenges = await Challenge.query()
      .whereHas('createdBy', (creator) =>
        creator.whereHas('profile', (profile) => profile.where('isPublic', true))
      )
      .preload('createdBy')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))
    challenges.queryString({ page, perPage })

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Despite us adding page to the queryString() method, the URL generation will override it to allow linking to the correct page. If we had additional things we were filtering by, we'd use this same approach to keep those filters in our pagination links.

Now, one more thing while we're in our controller. When paginating, it's always recommended to sort your query. Without a sort, you run the possibility that your results will shuffle from page to page since you're just using your database's inherent sorting. So, let's add a quick sort to ours so we don't run that risk. We can also remove our createdBy filter and preload; we don't actually need them here.

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const challenges = await Challenge.query()
      .orderBy('createdAt', 'desc')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))
    challenges.queryString({ page, perPage })

    return view.render('pages/challenges/index', { challenges })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Similar to firstOrFail(), the paginate() method is a terminating method that stops our ability to chain off the query builder further. So, we'll add our order by before calling paginate. Then, we'll sort by createdAt in desc to order our challenges from newest to oldest.

Jumping back into our challenges.index page, let's add our individual link numbers using the getUrlsForRange() method.

@layout()

  <div>
    {{-- ... --}}

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
    
    <div style="display: flex; gap: .25rem;">
      @if (challenges.currentPage > 1)
        <a href="{{ challenges.getPreviousPageUrl() }}">Prev</a>
      @endif

      @each (anchor in challenges.getUrlsForRange(1, challenges.lastPage))
        <a href="{{ anchor.url }}">{{ anchor.page }}</a>
      @endeach

      @if (challenges.hasMorePages)
        <a href="{{ challenges.getNextPageUrl() }}">Next</a>
      @endif
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

The getUrlsForRange() method takes in the page range we want to generate URLs for. If we start at 1 and go to the last page, we'll generate the full gamut of URLs needed to get through all our pages of data. This method then returns:

{ url: string; page: number; isActive: boolean }[]
Copied!

If the generated URL is for the currently displayed page, then isActive will be true. So, we could use this to highlight the active page.

@layout()

  <div>
    {{-- ... --}}

    @challenge.grid() 
      @each(challenge in challenges)
        @!challenge.gridItem({ challenge }) 
      @endeach
    @end
    
    <div style="display: flex; gap: .25rem;">
      @if (challenges.currentPage > 1)
        <a href="{{ challenges.getPreviousPageUrl() }}">Prev</a>
      @endif

      @each (anchor in challenges.getUrlsForRange(1, challenges.lastPage))
        <a href="{{ anchor.url }}" style="color: {{ anchor.isActive ? 'blue' : null }}">
          {{ anchor.page }}
        </a>
      @endeach

      @if (challenges.hasMorePages)
        <a href="{{ challenges.getNextPageUrl() }}">Next</a>
      @endif
    </div>
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

Now, whatever page we're actively on will be blue instead of a dark gray.

Okay, last thing. As you go from page to page, notice how our total available points is changing? This is because it's the total available points for the current page of challenges instead of all challenges. Let's query a sum of our total available points to fix this.

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const totalPoints = await Challenge.query().sum('points', 'total').firstOrFail()
    const challenges = await Challenge.query()
      .orderBy('createdAt', 'desc')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))
    challenges.queryString({ page, perPage })

    return view.render('pages/challenges/index', { challenges, totalPoints })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Here we're using the sum() query builder method to sum all row's points column into a single value. This method also accepts the name/alias as the second argument, so we're calling it total. We're using the model query builder, so our sum will be at totalPoints.$extras.total. If we'd rather just get the value, we can instead use the database query builder.

import Challenge from '#models/challenge'
import { challengeIndexValidator, challengeValidator } from '#validators/challenge'
import type { HttpContext } from '@adonisjs/core/http'
import { urlFor } from '@adonisjs/core/services/url_builder'
import db from '@adonisjs/lucid/services/db'

export default class ChallengesController {
  /**
   * Display a list of resource
   */
  async index({ request, view }: HttpContext) {
    const { page, perPage } = await request.validateUsing(challengeIndexValidator)
    const { totalPoints } = await db.from('challenges').sum('points', 'totalPoints').firstOrFail()
    const challenges = await Challenge.query()
      .orderBy('createdAt', 'desc')
      .paginate(page, perPage)

    challenges.baseUrl(urlFor('challenges.index'))
    challenges.queryString({ page, perPage })

    return view.render('pages/challenges/index', { challenges, totalPoints })
  }

  // ...
}
Copied!
  • app
  • controllers
  • challenges_controller.ts

Remember, this is a lower-level query builder that doesn't go through our models. So, we need to specify tables and columns exactly as they're named in our database. To query from a table, we can use the from(tableName) method. Since this doesn't go through our models, we get back an object representation of what we have queried/selected. Meaning, we can destructure our totalPoints sum directly from our results for use.

Great, all that's left is to swap that into our page! This also removes the need for our template logic to aggregate this, so we can get rid of the partials/challenge/available_points partial we created, as that's no longer needed.

@layout()

  <div>
    @if (session.has('success'))
      <div class="alert alert-success">
        {{ session.get('success') }}
      </div>
    @endif

    <div class="hero">
      <h1>Challenges</h1>

      <div>
        Total Points Available: {{ totalPoints.toLocaleString() }}
      </div>

      <div>
        Total Challenges: {{ challenges.total }}
      </div>
  
      <a href="{{ route('challenges.create') }}" class="button">Create a new challenge</a>
    </div>

    {{-- ... --}}
  </div>

@end
Copied!
  • resources
  • views
  • pages
  • challenges
  • index.edge

Since the sum is a number, we can also format the number (ex, 1,500), using the toLocaleString() method.

Join the Discussion 0 comments

Create a free account to join in on the discussion
robot comment bubble

Be the first to comment!