Wiring URL Query Params to Ember Data for Server-Side Pagination
Map URL query parameters to Ember Data requests so pagination stays bookmarkable and back-button friendly. A practical guide to route model hooks, findAll queries, and the server contract you need to enforce.
27 Aug 2026, 05:27 UTC

The Bookmark Problem
Pagination that lives only in JavaScript memory is fragile. If a user reloads the page, hits the back button, or copies the URL to share a specific page of results, client-side state disappears. The fix is to treat the URL as the source of truth. In Ember, that means mapping URL query parameters directly to the arguments your route uses when it asks the store for data.
The useful takeaway: Ember’s queryParams configuration on a route, combined with the query option on store.findAll, gives you server-side pagination without extra libraries. The URL stays readable, the model hook stays declarative, and the back button works.
Mapping URL State to the Model Hook
Ember routes expose a queryParams object that declares which URL parameters the route cares about. When you set refreshModel: true on a parameter, Ember reruns the model hook whenever that parameter changes. Inside the hook, this.paramsFor('route-name') returns the current query parameter values, so you can pass them straight into a store request.
This integration matters because Ember’s transition system handles the async work for you. When a user clicks “next page,” the router updates the URL, reruns the model hook, and waits for the promise to resolve before rendering. You do not need to manually cancel in-flight requests or manage loading substates.
Worked Example: A Paginated Posts Route
Assume a modern Ember application using native class syntax, backed by an endpoint that accepts page and pageSize query parameters. Generate the route from your terminal at the project root:
ember generate route posts
Expected check: app/routes/posts.js and a matching template are created. Risk: if a route file already exists, the generator will prompt before overwriting it.
Define the route so URL changes trigger a fresh model lookup:
// app/routes/posts.js
import Route from '@ember/routing/route';
export default class PostsRoute extends Route {
queryParams = {
page: { refreshModel: true },
pageSize: { refreshModel: true }
};
async model() {
const { page, pageSize } = this.paramsFor('posts');
return this.store.findAll('post', {
query: { page, pageSize }
});
}
}
Next, bind the parameters to controller properties so template controls can update them:
// app/controllers/posts.js
import Controller from '@ember/controller';
import { action } from '@ember/object';
export default class PostsController extends Controller {
queryParams = ['page', 'pageSize'];
page = 1;
pageSize = 10;
@action
nextPage() {
this.set('page', this.page + 1);
}
}
A minimal template can then offer paging controls that update the URL:
{{!-- app/templates/posts.hbs --}}
<button {{on "click" this.nextPage}}>Next</button>
<ul>
{{#each this.model as |post|}}
<li>{{post.title}}</li>
{{/each}}
</ul>
When a user navigates to /posts?page=2&pageSize=10, the model hook reads those values and calls findAll with { query: { page: 2, pageSize: 10 } }. The adapter serializes that object into the HTTP request.
The Server Contract You Must Enforce
This pattern only works if the server agrees on the parameter names. If your API expects page_number instead of page, the request will succeed but return the wrong data. Inspect the network tab in your browser’s developer tools: the request URL should contain the query string you expect, and the response should include the records plus enough metadata to render paging controls.
Most pagination UIs need a total count or a “next page” indicator. Ember Data does not invent this for you. Your adapter or serializer must extract it from the response—commonly from a JSON:API meta member or a custom response header—and expose it to the route or controller. Without that total, you can offer a “Next” button but you cannot reliably show “Page 3 of 12.”
Trade-offs: Refresh Semantics and Over-Fetching
Using refreshModel: true is explicit and easy to reason about, but it means every page change reruns the model hook and issues a new network request. That is usually correct for pagination, yet it can feel heavy if you also bind unrelated query parameters to the same route. Keep pagination parameters isolated so a filter change does not accidentally discard the current page unless you intend it to.
Over-fetching is the other common trap. A pageSize of 100 or 500 reduces round trips but increases payload size and memory pressure in the browser. Because Ember Data tracks query results, flipping through many large pages can accumulate records in the store. Set a conservative default pageSize, validate it on the server, and consider adapter-level limits if the API allows them.
Practical Verification
To check that the wiring works, start your development server from the project root with ember serve. Navigate to /posts?page=2&pageSize=10. Confirm three things: the URL retains the parameters after the transition, the network request includes ?page=2&pageSize=10, and the rendered list corresponds to the second page rather than the first. If the list does not change, verify that refreshModel: true is set on both parameters and that the server is actually filtering by them.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.