When you're dealing with portal pages that display dozens or hundreds of records, loading everything at once becomes a problem. Pages load slowly, users have to scroll forever, and the whole experience just feels clunky. That's where pagination saves the day—split your data into smaller chunks, and let users navigate through pages instead.
I'll show you how to build a paginated fleet vehicle list in Odoo 19. We'll create a controller, set up search and filter options, and wire it all together with a QWeb template.
Why Bother with Pagination?
Simple answer: performance and usability. If you've got fifty vehicles to display, showing them all at once means a slow page load and a lot of scrolling. Break it into pages of five or ten, and suddenly everything feels faster and easier to navigate.
This works great for customer portals where users need to browse orders, invoices, fleet vehicles, or support tickets. Odoo already gives us a pager helper that does most of the work, so we just need to wire it up properly.
How It Works
The logic lives in your controller. You calculate how many records match your filters, create a pager object, and then fetch only the records needed for the current page. Pass everything to your template, and Odoo handles the pagination UI automatically.
Here's the flow:
- Define routes for your portal page (/fleet and /fleet/page/)</int:page>)
- Build search and filter options
- Count total matching records
- Create the pager with portal_pager
- Fetch only the records for the current page using limit and offset
- Render the template
The Controller
Here's a complete example for a fleet portal with search, filters, and pagination:
from odoo import http
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
class FleetPortal(CustomerPortal):
@http.route([
'/fleet',
'/fleet/page/<int:page>',
], type='http', auth='user', website=True)
def portal_fleet(
self,
page=1,
search=None,
search_in='all',
filterby='all',
date_begin=None,
date_end=None,
sortby=None,
**kwargs
):
Fleet = request.env['fleet.vehicle'].sudo()
searchbar_inputs = {
'all': {
'label': 'All',
'input': 'all',
'domain': [],
},
'name': {
'label': 'Vehicle Name',
'input': 'name',
'domain': [('name', 'ilike', search)] if search else [],
},
'license_plate': {
'label': 'License Plate',
'input': 'license_plate',
'domain': [('license_plate', 'ilike', search)] if search else [],
},
'status': {
'label': 'Status',
'input': 'status',
'domain': [('state_id.name', 'ilike', search)] if search else [],
},
}
searchbar_filters = {
'all': {
'label': 'All',
'domain': [],
},
'registered': {
'label': 'Registered',
'domain': [('state_id.name', '=', 'Registered')],
},
'downgraded': {
'label': 'Downgraded',
'domain': [('state_id.name', '=', 'Downgraded')],
},
}
search_domain = searchbar_inputs.get(search_in, searchbar_inputs['all'])['domain']
filter_domain = searchbar_filters.get(filterby, searchbar_filters['all'])['domain']
base_domain = [('driver_id', '=', request.env.user.partner_id.id)]
domain = base_domain + search_domain + filter_domain
total_vehicles = Fleet.search_count(domain)
step = 3
pager = portal_pager(
url='/fleet',
url_args={
'search': search,
'search_in': search_in,
'filterby': filterby,
'sortby': sortby,
'date_begin': date_begin,
'date_end': date_end,
},
total=total_vehicles,
page=page,
step=step,
)
fleet_records = Fleet.search(
domain,
limit=step,
offset=pager['offset'],
order='name asc',
)
values = {
'fleet_records': fleet_records,
'page_name': 'fleet',
'default_url': '/fleet',
'pager': pager,
'searchbar_inputs': searchbar_inputs,
'search_in': search_in,
'search': search,
'searchbar_filters': searchbar_filters,
'filterby': filterby,
'sortby': sortby,
'date_begin': date_begin,
'date_end': date_end,
}
return request.render('your_module.portal_fleet_template', values)
This extends CustomerPortal and creates two routes. We define search inputs and filters as dictionaries, combine them into a domain, count the total records, and create a pager. Then we fetch just the records we need for the current page.
Key Pager Parameters
The portal_pager function needs a few key arguments:
- url: Base URL for pagination links
- total: Total matching records
- page: Current page number
- step: Records per page
- url_args: Query parameters to preserve (search terms, filters)
Tweak step to show more or fewer records per page.
The Template
Here's the QWeb template that renders the records and pager:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="portal_fleet_template" name="Portal Fleet List">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="o_portal_wrap">
<div class="container">
<div class="row mt-3">
<div class="col-12">
<h2>My Fleet</h2>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<form method="get" action="/fleet" class="o_portal_search_form">
<div class="row g-2">
<div class="col-md-4">
<input type="text"
name="search"
t-att-value="search or ''"
class="form-control"
placeholder="Search vehicles..."/>
</div>
<div class="col-md-3">
<select name="search_in" class="form-select">
<t t-foreach="searchbar_inputs.items()" t-as="s">
<option t-att-value="s[0]"
t-att-selected="s[0] == search_in and 'selected' or None">
<t t-esc="s[1]['label']"/>
</option>
</t>
</select>
</div>
<div class="col-md-3">
<select name="filterby" class="form-select">
<t t-foreach="searchbar_filters.items()" t-as="f">
<option t-att-value="f[0]"
t-att-selected="f[0] == filterby and 'selected' or None">
<t t-esc="f[1]['label']"/>
</option>
</t>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">
Search
</button>
</div>
</div>
</form>
</div>
</div>
<div class="row mt-4">
<div class="col-12">
<table class="table table-striped table-hover">
<thead class="table-light">
<tr>
<th>Vehicle</th>
<th>License Plate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<t t-if="fleet_records">
<t t-foreach="fleet_records" t-as="vehicle">
<tr>
<td><t t-esc="vehicle.name"/></td>
<td><t t-esc="vehicle.license_plate"/></td>
<td><t t-esc="vehicle.state_id.name or 'N/A'"/></td>
</tr>
</t>
</t>
<t t-else="">
<tr>
<td colspan="3" class="text-center text-muted py-4">
No vehicles found.
</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<t t-call="portal.pager">
<t t-set="classname" t-value="'d-flex justify-content-center'"/>
</t>
</div>
</div>
</div>
</div>
</t>
</template>
<odoo>
The template uses portal.portal_layout for the standard portal structure, renders a search form with dropdowns populated from our controller dictionaries, displays a table of records, and calls portal.pager to render the pagination controls.
Taking It Further
Once you've got basic pagination working, you can extend it pretty easily:
- Add sorting by name, date, or status
- Adjust page size based on the user's device
- Add date range filters
- Apply the same pattern to orders, invoices, or custom models
The pattern's modular, so you can reuse it across different portal pages with minimal changes.
Wrapping Up
Pagination isn't rocket science, but it makes a big difference in how users experience your portal. Odoo 19 gives you the tools you need. Just extend CustomerPortal, use portal_pager, and render the results in your template. Keep pages small, preserve search parameters, and your users will have a smooth browsing experience even with hundreds of records.
To read more about How to Add Pagination in a Website Portal in Odoo 18, refer to our blog, How to Add Pagination in a Website Portal in Odoo 18.