diff --git a/API.md b/API.md
index 8b25a51..abdc842 100644
--- a/API.md
+++ b/API.md
@@ -404,7 +404,7 @@ data.
 ## Listing tags
 - **Request**
 
-    `GET /tags/?page=<page>&pageSize=<page-size>&query=<query>`
+    `GET /tags/?offset=<initial-pos>&limit=<page-size>&query=<query>`
 
 - **Output**
 
@@ -675,7 +675,7 @@ data.
 ## Listing posts
 - **Request**
 
-    `GET /posts/?page=<page>&pageSize=<page-size>&query=<query>`
+    `GET /posts/?offset=<initial-pos>&limit=<page-size>&query=<query>`
 
 - **Output**
 
@@ -1102,7 +1102,7 @@ data.
 ## Listing comments
 - **Request**
 
-    `GET /comments/?page=<page>&pageSize=<page-size>&query=<query>`
+    `GET /comments/?offset=<initial-pos>&limit=<page-size>&query=<query>`
 
 - **Output**
 
@@ -1291,7 +1291,7 @@ data.
 ## Listing users
 - **Request**
 
-    `GET /users/?page=<page>&pageSize=<page-size>&query=<query>`
+    `GET /users/?offset=<initial-pos>&limit=<page-size>&query=<query>`
 
 - **Output**
 
@@ -1539,7 +1539,7 @@ data.
 ## Listing snapshots
 - **Request**
 
-    `GET /snapshots/?page=<page>&pageSize=<page-size>&query=<query>`
+    `GET /snapshots/?offset=<initial-pos>&limit=<page-size>&query=<query>`
 
 - **Output**
 
@@ -2166,9 +2166,9 @@ A result of search operation that involves paging.
 
 ```json5
 {
-    "query":    <query>, // same as in input
-    "page":     <page>,  // same as in input
-    "pageSize": <page-size>,
+    "query":    <query>,  // same as in input
+    "offset":   <offset>, // same as in input
+    "limit":    <page-size>,
     "total":    <total-count>,
     "results": [
         <resource>,
@@ -2181,7 +2181,7 @@ A result of search operation that involves paging.
 **Field meaning**
 - `<query>`: the query passed in the original request that contains standard
   [search query](#search).
-- `<page>`: the page number, passed in the original request.
+- `<offset>`: the record starting offset, passed in the original request.
 - `<page-size>`: number of records on one page.
 - `<total-count>`: how many resources were found. To get the page count, divide
   this number by `<page-size>`.
diff --git a/client/html/comments_page.tpl b/client/html/comments_page.tpl
index b117bf9..27d7011 100644
--- a/client/html/comments_page.tpl
+++ b/client/html/comments_page.tpl
@@ -1,6 +1,6 @@
 <div class='global-comment-list'>
     <ul><!--
-        --><% for (let post of ctx.results) { %><!--
+        --><% for (let post of ctx.response.results) { %><!--
             --><li><!--
                 --><div class='post-thumbnail'><!--
                     --><% if (ctx.canViewPosts) { %><!--
diff --git a/client/html/manual_pager_nav.tpl b/client/html/manual_pager_nav.tpl
index d47df14..23703e9 100644
--- a/client/html/manual_pager_nav.tpl
+++ b/client/html/manual_pager_nav.tpl
@@ -1,8 +1,8 @@
 <nav class='buttons'>
     <ul>
         <li>
-            <% if (ctx.prevLinkActive) { %>
-                <a class='prev' href='<%- ctx.prevLink %>'>
+            <% if (ctx.prevPage !== ctx.currentPage) { %>
+                <a class='prev' href='<%- ctx.getClientUrlForPage(ctx.pages.get(ctx.prevPage).offset, ctx.pages.get(ctx.prevPage).limit) %>'>
             <% } else { %>
                 <a class='prev disabled'>
             <% } %>
@@ -11,7 +11,7 @@
             </a>
         </li>
 
-        <% for (let page of ctx.pages) { %>
+        <% for (let page of ctx.pages.values()) { %>
             <% if (page.ellipsis) { %>
                 <li>&hellip;</li>
             <% } else { %>
@@ -20,14 +20,14 @@
                 <% } else { %>
                     <li>
                 <% } %>
-                    <a href='<%- page.link %>'><%- page.number %></a>
+                    <a href='<%- ctx.getClientUrlForPage(page.offset, page.limit) %>'><%- page.number %></a>
                 </li>
             <% } %>
         <% } %>
 
         <li>
-            <% if (ctx.nextLinkActive) { %>
-                <a class='next' href='<%- ctx.nextLink %>'>
+            <% if (ctx.nextPage !== ctx.currentPage) { %>
+                <a class='next' href='<%- ctx.getClientUrlForPage(ctx.pages.get(ctx.nextPage).offset, ctx.pages.get(ctx.nextPage).limit) %>'>
             <% } else { %>
                 <a class='next disabled'>
             <% } %>
diff --git a/client/html/posts_page.tpl b/client/html/posts_page.tpl
index 9c9f7ef..79f31d9 100644
--- a/client/html/posts_page.tpl
+++ b/client/html/posts_page.tpl
@@ -1,7 +1,7 @@
 <div class='post-list'>
-    <% if (ctx.results.length) { %>
+    <% if (ctx.response.results.length) { %>
         <ul>
-            <% for (let post of ctx.results) { %>
+            <% for (let post of ctx.response.results) { %>
                 <li>
                     <a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>'
                             title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') || 'none' %>'
diff --git a/client/html/snapshots_page.tpl b/client/html/snapshots_page.tpl
index c84e19f..6650d65 100644
--- a/client/html/snapshots_page.tpl
+++ b/client/html/snapshots_page.tpl
@@ -1,7 +1,7 @@
 <div class='snapshot-list'>
-    <% if (ctx.results.length) { %>
+    <% if (ctx.response.results.length) { %>
         <ul>
-            <% for (let item of ctx.results) { %>
+            <% for (let item of ctx.response.results) { %>
                 <li>
                     <div class='header operation-<%= item.operation %>'>
                         <span class='time'>
diff --git a/client/html/tags_page.tpl b/client/html/tags_page.tpl
index f27f49e..788d0e3 100644
--- a/client/html/tags_page.tpl
+++ b/client/html/tags_page.tpl
@@ -1,5 +1,5 @@
 <div class='tag-list'>
-    <% if (ctx.results.length) { %>
+    <% if (ctx.response.results.length) { %>
         <table>
             <thead>
                 <th class='names'>
@@ -39,7 +39,7 @@
                 </th>
             </thead>
             <tbody>
-                <% for (let tag of ctx.results) { %>
+                <% for (let tag of ctx.response.results) { %>
                     <tr>
                         <td class='names'>
                             <ul>
diff --git a/client/html/users_page.tpl b/client/html/users_page.tpl
index 6cb04d3..f09c207 100644
--- a/client/html/users_page.tpl
+++ b/client/html/users_page.tpl
@@ -1,6 +1,6 @@
 <div class='user-list'>
     <ul><!--
-        --><% for (let user of ctx.results) { %><!--
+        --><% for (let user of ctx.response.results) { %><!--
             --><li>
                 <div class='wrapper'>
                     <% if (ctx.canViewUsers) { %>
diff --git a/client/js/controllers/comments_controller.js b/client/js/controllers/comments_controller.js
index ce88632..2de638c 100644
--- a/client/js/controllers/comments_controller.js
+++ b/client/js/controllers/comments_controller.js
@@ -25,14 +25,16 @@ class CommentsController {
         this._pageController = new PageController();
         this._pageController.run({
             parameters: ctx.parameters,
-            getClientUrlForPage: page => {
+            defaultLimit: 10,
+            getClientUrlForPage: (offset, limit) => {
                 const parameters = Object.assign(
-                    {}, ctx.parameters, {page: page});
+                    {}, ctx.parameters, {offset: offset, limit: limit});
                 return uri.formatClientLink('comments', parameters);
             },
-            requestPage: page => {
+            requestPage: (offset, limit) => {
                 return PostList.search(
-                    'sort:comment-date comment-count-min:1', page, 10, fields);
+                    'sort:comment-date comment-count-min:1',
+                    offset, limit, fields);
             },
             pageRenderer: pageCtx => {
                 Object.assign(pageCtx, {
diff --git a/client/js/controllers/page_controller.js b/client/js/controllers/page_controller.js
index 6f86e0c..ae55804 100644
--- a/client/js/controllers/page_controller.js
+++ b/client/js/controllers/page_controller.js
@@ -18,12 +18,6 @@ class PageController {
     }
 
     run(ctx) {
-        const extendedContext = {
-            getClientUrlForPage: ctx.getClientUrlForPage,
-            parameters: ctx.parameters,
-        };
-
-        ctx.pageContext = Object.assign({}, extendedContext);
         this._view.run(ctx);
     }
 
diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js
index 1e6dd7c..bd570e9 100644
--- a/client/js/controllers/post_list_controller.js
+++ b/client/js/controllers/post_list_controller.js
@@ -88,14 +88,17 @@ class PostListController {
     _syncPageController() {
         this._pageController.run({
             parameters: this._ctx.parameters,
-            getClientUrlForPage: page => {
-                return uri.formatClientLink('posts',
-                    Object.assign({}, this._ctx.parameters, {page: page}));
+            defaultLimit: parseInt(settings.get().postsPerPage),
+            getClientUrlForPage: (offset, limit) => {
+                const parameters = Object.assign(
+                    {}, this._ctx.parameters, {offset: offset, limit: limit});
+                return uri.formatClientLink('posts', parameters);
             },
-            requestPage: page => {
+            requestPage: (offset, limit) => {
                 return PostList.search(
-                    this._decorateSearchQuery(this._ctx.parameters.query),
-                    page, settings.get().postsPerPage, fields);
+                    this._decorateSearchQuery(
+                        this._ctx.parameters.query || ''),
+                    offset, limit, fields);
             },
             pageRenderer: pageCtx => {
                 Object.assign(pageCtx, {
diff --git a/client/js/controllers/post_main_controller.js b/client/js/controllers/post_main_controller.js
index ba5f688..c672a0f 100644
--- a/client/js/controllers/post_main_controller.js
+++ b/client/js/controllers/post_main_controller.js
@@ -21,7 +21,7 @@ class PostMainController extends BasePostController {
                 Post.get(ctx.parameters.id),
                 PostList.getAround(
                     ctx.parameters.id, this._decorateSearchQuery(
-                        parameters ? parameters.query : '')),
+                        parameters ? parameters.query || '' : '')),
         ]).then(responses => {
             const [post, aroundResponse] = responses;
 
diff --git a/client/js/controllers/snapshots_controller.js b/client/js/controllers/snapshots_controller.js
index 034bf49..e64b9db 100644
--- a/client/js/controllers/snapshots_controller.js
+++ b/client/js/controllers/snapshots_controller.js
@@ -22,13 +22,14 @@ class SnapshotsController {
         this._pageController = new PageController();
         this._pageController.run({
             parameters: ctx.parameters,
-            getClientUrlForPage: page => {
+            defaultLimit: 25,
+            getClientUrlForPage: (offset, limit) => {
                 const parameters = Object.assign(
-                    {}, ctx.parameters, {page: page});
+                    {}, ctx.parameters, {offset: offset, limit: limit});
                 return uri.formatClientLink('history', parameters);
             },
-            requestPage: page => {
-                return SnapshotList.search('', page, 25);
+            requestPage: (offset, limit) => {
+                return SnapshotList.search('', offset, limit);
             },
             pageRenderer: pageCtx => {
                 Object.assign(pageCtx, {
diff --git a/client/js/controllers/tag_list_controller.js b/client/js/controllers/tag_list_controller.js
index 2b485d2..2598143 100644
--- a/client/js/controllers/tag_list_controller.js
+++ b/client/js/controllers/tag_list_controller.js
@@ -57,14 +57,15 @@ class TagListController {
     _syncPageController() {
         this._pageController.run({
             parameters: this._ctx.parameters,
-            getClientUrlForPage: page => {
+            defaultLimit: 50,
+            getClientUrlForPage: (offset, limit) => {
                 const parameters = Object.assign(
-                    {}, this._ctx.parameters, {page: page});
+                    {}, this._ctx.parameters, {offset: offset, limit: limit});
                 return uri.formatClientLink('tags', parameters);
             },
-            requestPage: page => {
+            requestPage: (offset, limit) => {
                 return TagList.search(
-                    this._ctx.parameters.query, page, 50, fields);
+                    this._ctx.parameters.query, offset, limit, fields);
             },
             pageRenderer: pageCtx => {
                 return new TagsPageView(pageCtx);
diff --git a/client/js/controllers/user_list_controller.js b/client/js/controllers/user_list_controller.js
index 13aec00..5cf4b2b 100644
--- a/client/js/controllers/user_list_controller.js
+++ b/client/js/controllers/user_list_controller.js
@@ -49,13 +49,15 @@ class UserListController {
     _syncPageController() {
         this._pageController.run({
             parameters: this._ctx.parameters,
-            getClientUrlForPage: page => {
+            defaultLimit: 30,
+            getClientUrlForPage: (offset, limit) => {
                 const parameters = Object.assign(
-                    {}, this._ctx.parameters, {page: page});
+                    {}, this._ctx.parameters, {offset, offset, limit: limit});
                 return uri.formatClientLink('users', parameters);
             },
-            requestPage: page => {
-                return UserList.search(this._ctx.parameters.query, page);
+            requestPage: (offset, limit) => {
+                return UserList.search(
+                    this._ctx.parameters.query || '', offset, limit);
             },
             pageRenderer: pageCtx => {
                 Object.assign(pageCtx, {
diff --git a/client/js/models/post_list.js b/client/js/models/post_list.js
index 2af40f5..244e781 100644
--- a/client/js/models/post_list.js
+++ b/client/js/models/post_list.js
@@ -12,13 +12,13 @@ class PostList extends AbstractList {
                 'post', id, 'around', {query: searchQuery, fields: 'id'}));
     }
 
-    static search(text, page, pageSize, fields) {
+    static search(text, offset, limit, fields) {
         return api.get(
                 uri.formatApiLink(
                     'posts', {
                         query: text,
-                        page: page,
-                        pageSize: pageSize,
+                        offset: offset,
+                        limit: limit,
                         fields: fields.join(','),
                     }))
             .then(response => {
diff --git a/client/js/models/snapshot_list.js b/client/js/models/snapshot_list.js
index a23850a..3263a6a 100644
--- a/client/js/models/snapshot_list.js
+++ b/client/js/models/snapshot_list.js
@@ -6,9 +6,9 @@ const AbstractList = require('./abstract_list.js');
 const Snapshot = require('./snapshot.js');
 
 class SnapshotList extends AbstractList {
-    static search(text, page, pageSize) {
+    static search(text, offset, limit) {
         return api.get(uri.formatApiLink(
-                'snapshots', {query: text, page: page, pageSize: pageSize}))
+                'snapshots', {query: text, offset: offset, limit: limit}))
             .then(response => {
                 return Promise.resolve(Object.assign(
                     {},
diff --git a/client/js/models/tag_list.js b/client/js/models/tag_list.js
index d480745..282d4ef 100644
--- a/client/js/models/tag_list.js
+++ b/client/js/models/tag_list.js
@@ -6,13 +6,13 @@ const AbstractList = require('./abstract_list.js');
 const Tag = require('./tag.js');
 
 class TagList extends AbstractList {
-    static search(text, page, pageSize, fields) {
+    static search(text, offset, limit, fields) {
         return api.get(
                 uri.formatApiLink(
                     'tags', {
                         query: text,
-                        page: page,
-                        pageSize: pageSize,
+                        offset: offset,
+                        limit: limit,
                         fields: fields.join(','),
                     }))
             .then(response => {
diff --git a/client/js/models/user_list.js b/client/js/models/user_list.js
index ff3e27c..c48fc88 100644
--- a/client/js/models/user_list.js
+++ b/client/js/models/user_list.js
@@ -6,10 +6,10 @@ const AbstractList = require('./abstract_list.js');
 const User = require('./user.js');
 
 class UserList extends AbstractList {
-    static search(text, page) {
+    static search(text, offset, limit) {
         return api.get(
                 uri.formatApiLink(
-                    'users', {query: text, page: page, pageSize: 30}))
+                    'users', {query: text, offset: offset, limit: limit}))
             .then(response => {
                 return Promise.resolve(Object.assign(
                     {},
diff --git a/client/js/router.js b/client/js/router.js
index 9bff064..64f467e 100644
--- a/client/js/router.js
+++ b/client/js/router.js
@@ -112,10 +112,6 @@ class Route {
             return false;
         }
 
-        // XXX: it is very unfitting place for this
-        parameters.query = parameters.query || '';
-        parameters.page = parseInt(parameters.page || '1');
-
         return true;
     }
 };
diff --git a/client/js/views/comments_page_view.js b/client/js/views/comments_page_view.js
index 2722bd6..d5bb294 100644
--- a/client/js/views/comments_page_view.js
+++ b/client/js/views/comments_page_view.js
@@ -13,7 +13,7 @@ class CommentsPageView extends events.EventTarget {
 
         const sourceNode = template(ctx);
 
-        for (let post of ctx.results) {
+        for (let post of ctx.response.results) {
             const commentListControl = new CommentListControl(
                 sourceNode.querySelector(
                     `.comments-container[data-for="${post.id}"]`),
diff --git a/client/js/views/endless_page_view.js b/client/js/views/endless_page_view.js
index c70d417..20f3b97 100644
--- a/client/js/views/endless_page_view.js
+++ b/client/js/views/endless_page_view.js
@@ -21,16 +21,19 @@ class EndlessPageView {
         views.emptyContent(this._pagesHolderNode);
 
         this.threshold = window.innerHeight / 3;
-        this.minPageShown = null;
-        this.maxPageShown = null;
-        this.totalPages = null;
-        this.currentPage = null;
+        this.minOffsetShown = null;
+        this.maxOffsetShown = null;
+        this.totalRecords = null;
+        this.currentOffset = 0;
 
-        this._loadPage(ctx, ctx.parameters.page, true).then(pageNode => {
-            if (ctx.parameters.page !== 1) {
-                pageNode.scrollIntoView();
-            }
-        });
+        const offset = parseInt(ctx.parameters.offset || 0);
+        const limit = parseInt(ctx.parameters.limit || ctx.defaultLimit);
+        this._loadPage(ctx, offset, limit, true)
+            .then(pageNode => {
+                if (offset !== 0) {
+                    pageNode.scrollIntoView();
+                }
+            });
         this._probePageLoad(ctx);
 
         views.monitorNodeRemoval(this._pagesHolderNode, () => this._destroy());
@@ -75,42 +78,45 @@ class EndlessPageView {
         if (!topPageNode) {
             return;
         }
-        let topPageNumber = parseInt(topPageNode.getAttribute('data-page'));
-        if (topPageNumber !== this.currentPage) {
+        let topOffset = parseInt(topPageNode.getAttribute('data-offset'));
+        let topLimit = parseInt(topPageNode.getAttribute('data-limit'));
+        if (topOffset !== this.currentOffset) {
             router.replace(
-                ctx.getClientUrlForPage(topPageNumber),
+                ctx.getClientUrlForPage(
+                    topOffset,
+                    topLimit === ctx.defaultLimit ? null : topLimit),
                 ctx.state,
                 false);
-            this.currentPage = topPageNumber;
+            this.currentOffset = topOffset;
         }
 
-        if (this.totalPages === null) {
+        if (this.totalRecords === null) {
             return;
         }
         let scrollHeight =
             document.documentElement.scrollHeight -
             document.documentElement.clientHeight;
 
-        if (this.minPageShown > 1 && window.scrollY < this.threshold) {
-            this._loadPage(ctx, this.minPageShown - 1, false);
-        } else if (this.maxPageShown < this.totalPages &&
+        if (this.minOffsetShown > 0 && window.scrollY < this.threshold) {
+            this._loadPage(
+                ctx, this.minOffsetShown - topLimit, topLimit, false);
+        } else if (this.maxOffsetShown < this.totalRecords &&
                 window.scrollY + this.threshold > scrollHeight) {
-            this._loadPage(ctx, this.maxPageShown + 1, true);
+            this._loadPage(
+                ctx, this.maxOffsetShown, topLimit, true);
         }
     }
 
-    _loadPage(ctx, pageNumber, append) {
+    _loadPage(ctx, offset, limit, append) {
         this._working++;
         return new Promise((resolve, reject) => {
-            ctx.requestPage(pageNumber).then(response => {
+            ctx.requestPage(offset, limit).then(response => {
                 if (!this._active) {
                     this._working--;
                     return Promise.reject();
                 }
-                this.totalPages = Math.ceil(response.total / response.pageSize);
                 window.requestAnimationFrame(() => {
-                    let pageNode = this._renderPage(
-                        ctx, pageNumber, append, response);
+                    let pageNode = this._renderPage(ctx, append, response);
                     this._working--;
                     resolve(pageNode);
                 });
@@ -122,33 +128,40 @@ class EndlessPageView {
         });
     }
 
-    _renderPage(ctx, pageNumber, append, response) {
+    _renderPage(ctx, append, response) {
         let pageNode = null;
 
         if (response.total) {
             pageNode = pageTemplate({
-                page: pageNumber,
-                totalPages: this.totalPages,
+                totalPages: Math.ceil(response.total / response.limit),
+                page: Math.ceil(
+                    (response.offset + response.limit) / response.limit),
             });
-            pageNode.setAttribute('data-page', pageNumber);
+            pageNode.setAttribute('data-offset', response.offset);
+            pageNode.setAttribute('data-limit', response.limit);
 
-            Object.assign(ctx.pageContext, response);
-            ctx.pageContext.hostNode = pageNode.querySelector(
-                '.page-content-holder');
-            ctx.pageRenderer(ctx.pageContext);
+            ctx.pageRenderer({
+                parameters: ctx.parameters,
+                response: response,
+                hostNode: pageNode.querySelector('.page-content-holder'),
+            });
 
-            if (pageNumber < this.minPageShown ||
-                    this.minPageShown === null) {
-                this.minPageShown = pageNumber;
+            this.totalRecords = response.total;
+
+            if (response.offset < this.minOffsetShown ||
+                    this.minOffsetShown === null) {
+                this.minOffsetShown = response.offset;
             }
-            if (pageNumber > this.maxPageShown ||
-                    this.maxPageShown === null) {
-                this.maxPageShown = pageNumber;
+            if (response.offset + response.results.length
+                    > this.maxOffsetShown ||
+                    this.maxOffsetShown === null) {
+                this.maxOffsetShown =
+                    response.offset + response.results.length;
             }
 
             if (append) {
                 this._pagesHolderNode.appendChild(pageNode);
-                if (!this._init && pageNumber !== 1) {
+                if (!this._init && response.offset > 0) {
                     window.scroll(0, pageNode.getBoundingClientRect().top);
                 }
             } else {
@@ -158,7 +171,7 @@ class EndlessPageView {
                     window.scrollX,
                     window.scrollY + pageNode.offsetHeight);
             }
-        } else if (response.total <= (pageNumber - 1) * response.pageSize) {
+        } else if (!response.results.length) {
             this.showInfo('No data to show');
         }
 
diff --git a/client/js/views/manual_page_view.js b/client/js/views/manual_page_view.js
index e3767c0..93efdc4 100644
--- a/client/js/views/manual_page_view.js
+++ b/client/js/views/manual_page_view.js
@@ -35,19 +35,20 @@ function _getVisiblePageNumbers(currentPage, totalPages) {
     return pagesVisible;
 }
 
-function _getPages(currentPage, pageNumbers, ctx) {
-    const pages = [];
-    let lastPage = 0;
+function _getPages(currentPage, pageNumbers, ctx, limit) {
+    const pages = new Map();
+    let prevPage = 0;
     for (let page of pageNumbers) {
-        if (page !== lastPage + 1) {
-            pages.push({ellipsis: true});
+        if (page !== prevPage + 1) {
+            pages.set(page - 1, {ellipsis: true});
         }
-        pages.push({
+        pages.set(page, {
             number: page,
-            link: ctx.getClientUrlForPage(page),
+            offset: (page - 1) * limit,
+            limit: limit === ctx.defaultLimit ? null : limit,
             active: currentPage === page,
         });
-        lastPage = page;
+        prevPage = page;
     }
     return pages;
 }
@@ -59,43 +60,30 @@ class ManualPageView {
     }
 
     run(ctx) {
-        const currentPage = ctx.parameters.page;
+        const offset = parseInt(ctx.parameters.offset || 0);
+        const limit = parseInt(ctx.parameters.limit || ctx.defaultLimit);
         this.clearMessages();
         views.emptyContent(this._pageNavNode);
 
-        ctx.requestPage(currentPage).then(response => {
-            Object.assign(ctx.pageContext, response);
-            ctx.pageContext.hostNode = this._pageContentHolderNode;
-            ctx.pageRenderer(ctx.pageContext);
-
-            const totalPages = Math.ceil(response.total / response.pageSize);
-            const pageNumbers = _getVisiblePageNumbers(currentPage, totalPages);
-            const pages = _getPages(currentPage, pageNumbers, ctx);
+        ctx.requestPage(offset, limit).then(response => {
+            ctx.pageRenderer({
+                parameters: ctx.parameters,
+                response: response,
+                hostNode: this._pageContentHolderNode,
+            });
 
             keyboard.bind(['a', 'left'], () => {
-                if (currentPage > 1) {
-                    router.show(ctx.getClientUrlForPage(currentPage - 1));
-                }
+                this._navigateToPrevNextPage('prev');
             });
             keyboard.bind(['d', 'right'], () => {
-                if (currentPage < totalPages) {
-                    router.show(ctx.getClientUrlForPage(currentPage + 1));
-                }
+                this._navigateToPrevNextPage('next');
             });
 
             if (response.total) {
-                views.replaceContent(
-                    this._pageNavNode,
-                    navTemplate({
-                        prevLink: ctx.getClientUrlForPage(currentPage - 1),
-                        nextLink: ctx.getClientUrlForPage(currentPage + 1),
-                        prevLinkActive: currentPage > 1,
-                        nextLinkActive: currentPage < totalPages,
-                        pages: pages,
-                    }));
+                this._refreshNav(response, ctx);
             }
 
-            if (response.total <= (currentPage - 1) * response.pageSize) {
+            if (!response.results.length) {
                 this.showInfo('No data to show');
             }
 
@@ -132,6 +120,33 @@ class ManualPageView {
     showInfo(message) {
         views.showInfo(this._hostNode, message);
     }
+
+    _navigateToPrevNextPage(className) {
+        const linkNode = this._hostNode.querySelector('a.' + className);
+        if (linkNode.classList.contains('disabled')) {
+            return;
+        }
+        router.show(linkNode.getAttribute('href'));
+    }
+
+    _refreshNav(response, ctx) {
+        const currentPage = Math.ceil(
+            (response.offset + response.limit) / response.limit);
+        const totalPages = Math.ceil(response.total / response.limit);
+        const pageNumbers = _getVisiblePageNumbers(currentPage, totalPages);
+        const pages = _getPages(currentPage, pageNumbers, ctx, response.limit);
+
+        views.replaceContent(
+            this._pageNavNode,
+            navTemplate({
+                getClientUrlForPage: ctx.getClientUrlForPage,
+                prevPage: Math.min(totalPages, Math.max(1, currentPage - 1)),
+                nextPage: Math.min(totalPages, Math.max(1, currentPage + 1)),
+                currentPage: currentPage,
+                totalPages: totalPages,
+                pages: pages,
+            }));
+    }
 }
 
 module.exports = ManualPageView;
diff --git a/client/js/views/posts_header_view.js b/client/js/views/posts_header_view.js
index 04764c1..54b8cf0 100644
--- a/client/js/views/posts_header_view.js
+++ b/client/js/views/posts_header_view.js
@@ -89,7 +89,8 @@ class PostsHeaderView extends events.EventTarget {
         this._toggleMassTagVisibility(false);
         this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: {
             query: this._ctx.parameters.query,
-            page: this._ctx.parameters.page,
+            offset: this._ctx.parameters.offset,
+            limit: this._ctx.parameters.limit,
             tag: null,
         }}}));
     }
@@ -107,7 +108,7 @@ class PostsHeaderView extends events.EventTarget {
                 'navigate', {
                     detail: {
                         parameters: Object.assign(
-                            {}, this._ctx.parameters, {tag: null, page: 1}),
+                            {}, this._ctx.parameters, {tag: null, offset: 0}),
                     },
                 }));
     }
@@ -119,8 +120,8 @@ class PostsHeaderView extends events.EventTarget {
             this._masstagAutoCompleteControl.hide();
         }
         let parameters = {query: this._queryInputNode.value};
-        parameters.page = parameters.query === this._ctx.parameters.query ?
-            this._ctx.parameters.page : 1;
+        parameters.offset = parameters.query === this._ctx.parameters.query ?
+            this._ctx.parameters.offset : 0;
         if (this._massTagInputNode) {
             parameters.tag = this._massTagInputNode.value;
             this._massTagInputNode.blur();
diff --git a/client/js/views/posts_page_view.js b/client/js/views/posts_page_view.js
index 686adcf..3c53c84 100644
--- a/client/js/views/posts_page_view.js
+++ b/client/js/views/posts_page_view.js
@@ -13,7 +13,7 @@ class PostsPageView extends events.EventTarget {
         views.replaceContent(this._hostNode, template(ctx));
 
         this._postIdToPost = {};
-        for (let post of ctx.results) {
+        for (let post of ctx.response.results) {
             this._postIdToPost[post.id] = post;
             post.addEventListener('change', e => this._evtPostChange(e));
         }
diff --git a/server/szurubooru/search/executor.py b/server/szurubooru/search/executor.py
index 2819593..bdb5e94 100644
--- a/server/szurubooru/search/executor.py
+++ b/server/szurubooru/search/executor.py
@@ -78,8 +78,8 @@ class Executor:
     def execute(
         self,
         query_text: str,
-        page: int,
-        page_size: int
+        offset: int,
+        limit: int
     ) -> Tuple[int, List[model.Base]]:
         search_query = self.parser.parse(query_text)
         self.config.on_search_query_parsed(search_query)
@@ -89,7 +89,7 @@ class Executor:
             if token.name == 'random':
                 disable_eager_loads = True
 
-        key = (id(self.config), hash(search_query), page, page_size)
+        key = (id(self.config), hash(search_query), offset, limit)
         if cache.has(key):
             return cache.get(key)
 
@@ -97,8 +97,8 @@ class Executor:
         filter_query = filter_query.options(sa.orm.lazyload('*'))
         filter_query = self._prepare_db_query(filter_query, search_query, True)
         entities = filter_query \
-            .offset(max(page - 1, 0) * page_size) \
-            .limit(page_size) \
+            .offset(offset) \
+            .limit(limit) \
             .all()
 
         count_query = self.config.create_count_query(disable_eager_loads)
@@ -120,14 +120,13 @@ class Executor:
         serializer: Callable[[model.Base], rest.Response]
     ) -> rest.Response:
         query = ctx.get_param_as_string('query', default='')
-        page = ctx.get_param_as_int('page', default=1, min=1)
-        page_size = ctx.get_param_as_int(
-            'pageSize', default=100, min=1, max=100)
-        count, entities = self.execute(query, page, page_size)
+        offset = ctx.get_param_as_int('offset', default=0, min=0)
+        limit = ctx.get_param_as_int('limit', default=100, min=1, max=100)
+        count, entities = self.execute(query, offset, limit)
         return {
             'query': query,
-            'page': page,
-            'pageSize': page_size,
+            'offset': offset,
+            'limit': limit,
             'total': count,
             'results': [serializer(entity) for entity in entities],
         }
diff --git a/server/szurubooru/tests/api/test_comment_retrieving.py b/server/szurubooru/tests/api/test_comment_retrieving.py
index e0378fa..5c846bb 100644
--- a/server/szurubooru/tests/api/test_comment_retrieving.py
+++ b/server/szurubooru/tests/api/test_comment_retrieving.py
@@ -23,12 +23,12 @@ def test_retrieving_multiple(user_factory, comment_factory, context_factory):
         comments.serialize_comment.return_value = 'serialized comment'
         result = api.comment_api.get_comments(
             context_factory(
-                params={'query': '', 'page': 1},
+                params={'query': '', 'offset': 0},
                 user=user_factory(rank=model.User.RANK_REGULAR)))
         assert result == {
             'query': '',
-            'page': 1,
-            'pageSize': 100,
+            'offset': 0,
+            'limit': 100,
             'total': 2,
             'results': ['serialized comment', 'serialized comment'],
         }
@@ -39,7 +39,7 @@ def test_trying_to_retrieve_multiple_without_privileges(
     with pytest.raises(errors.AuthError):
         api.comment_api.get_comments(
             context_factory(
-                params={'query': '', 'page': 1},
+                params={'query': '', 'offset': 0},
                 user=user_factory(rank=model.User.RANK_ANONYMOUS)))
 
 
diff --git a/server/szurubooru/tests/api/test_post_retrieving.py b/server/szurubooru/tests/api/test_post_retrieving.py
index 9d9db72..7270547 100644
--- a/server/szurubooru/tests/api/test_post_retrieving.py
+++ b/server/szurubooru/tests/api/test_post_retrieving.py
@@ -24,12 +24,12 @@ def test_retrieving_multiple(user_factory, post_factory, context_factory):
         posts.serialize_post.return_value = 'serialized post'
         result = api.post_api.get_posts(
             context_factory(
-                params={'query': '', 'page': 1},
+                params={'query': '', 'offset': 0},
                 user=user_factory(rank=model.User.RANK_REGULAR)))
         assert result == {
             'query': '',
-            'page': 1,
-            'pageSize': 100,
+            'offset': 0,
+            'limit': 100,
             'total': 2,
             'results': ['serialized post', 'serialized post'],
         }
@@ -48,12 +48,12 @@ def test_using_special_tokens(user_factory, post_factory, context_factory):
             'serialized post %d' % post.post_id
         result = api.post_api.get_posts(
             context_factory(
-                params={'query': 'special:fav', 'page': 1},
+                params={'query': 'special:fav', 'offset': 0},
                 user=auth_user))
         assert result == {
             'query': 'special:fav',
-            'page': 1,
-            'pageSize': 100,
+            'offset': 0,
+            'limit': 100,
             'total': 1,
             'results': ['serialized post 1'],
         }
@@ -67,7 +67,7 @@ def test_trying_to_use_special_tokens_without_logging_in(
     with pytest.raises(errors.SearchError):
         api.post_api.get_posts(
             context_factory(
-                params={'query': 'special:fav', 'page': 1},
+                params={'query': 'special:fav', 'offset': 0},
                 user=user_factory(rank=model.User.RANK_ANONYMOUS)))
 
 
@@ -76,7 +76,7 @@ def test_trying_to_retrieve_multiple_without_privileges(
     with pytest.raises(errors.AuthError):
         api.post_api.get_posts(
             context_factory(
-                params={'query': '', 'page': 1},
+                params={'query': '', 'offset': 0},
                 user=user_factory(rank=model.User.RANK_ANONYMOUS)))
 
 
diff --git a/server/szurubooru/tests/api/test_snapshot_retrieving.py b/server/szurubooru/tests/api/test_snapshot_retrieving.py
index facbcd8..41f0beb 100644
--- a/server/szurubooru/tests/api/test_snapshot_retrieving.py
+++ b/server/szurubooru/tests/api/test_snapshot_retrieving.py
@@ -28,11 +28,11 @@ def test_retrieving_multiple(user_factory, context_factory):
     db.session.flush()
     result = api.snapshot_api.get_snapshots(
         context_factory(
-            params={'query': '', 'page': 1},
+            params={'query': '', 'offset': 0},
             user=user_factory(rank=model.User.RANK_REGULAR)))
     assert result['query'] == ''
-    assert result['page'] == 1
-    assert result['pageSize'] == 100
+    assert result['offset'] == 0
+    assert result['limit'] == 100
     assert result['total'] == 2
     assert len(result['results']) == 2
 
@@ -42,5 +42,5 @@ def test_trying_to_retrieve_multiple_without_privileges(
     with pytest.raises(errors.AuthError):
         api.snapshot_api.get_snapshots(
             context_factory(
-                params={'query': '', 'page': 1},
+                params={'query': '', 'offset': 0},
                 user=user_factory(rank=model.User.RANK_ANONYMOUS)))
diff --git a/server/szurubooru/tests/api/test_tag_retrieving.py b/server/szurubooru/tests/api/test_tag_retrieving.py
index fd2b2cb..43a0766 100644
--- a/server/szurubooru/tests/api/test_tag_retrieving.py
+++ b/server/szurubooru/tests/api/test_tag_retrieving.py
@@ -23,12 +23,12 @@ def test_retrieving_multiple(user_factory, tag_factory, context_factory):
         tags.serialize_tag.return_value = 'serialized tag'
         result = api.tag_api.get_tags(
             context_factory(
-                params={'query': '', 'page': 1},
+                params={'query': '', 'offset': 0},
                 user=user_factory(rank=model.User.RANK_REGULAR)))
         assert result == {
             'query': '',
-            'page': 1,
-            'pageSize': 100,
+            'offset': 0,
+            'limit': 100,
             'total': 2,
             'results': ['serialized tag', 'serialized tag'],
         }
@@ -39,7 +39,7 @@ def test_trying_to_retrieve_multiple_without_privileges(
     with pytest.raises(errors.AuthError):
         api.tag_api.get_tags(
             context_factory(
-                params={'query': '', 'page': 1},
+                params={'query': '', 'offset': 0},
                 user=user_factory(rank=model.User.RANK_ANONYMOUS)))
 
 
diff --git a/server/szurubooru/tests/api/test_user_retrieving.py b/server/szurubooru/tests/api/test_user_retrieving.py
index 9be2620..2e797e8 100644
--- a/server/szurubooru/tests/api/test_user_retrieving.py
+++ b/server/szurubooru/tests/api/test_user_retrieving.py
@@ -28,8 +28,8 @@ def test_retrieving_multiple(user_factory, context_factory):
                 user=user_factory(rank=model.User.RANK_REGULAR)))
         assert result == {
             'query': '',
-            'page': 1,
-            'pageSize': 100,
+            'offset': 0,
+            'limit': 100,
             'total': 2,
             'results': ['serialized user', 'serialized user'],
         }
diff --git a/server/szurubooru/tests/search/configs/test_comment_search_config.py b/server/szurubooru/tests/search/configs/test_comment_search_config.py
index f2fe363..109629b 100644
--- a/server/szurubooru/tests/search/configs/test_comment_search_config.py
+++ b/server/szurubooru/tests/search/configs/test_comment_search_config.py
@@ -13,7 +13,7 @@ def executor():
 def verify_unpaged(executor):
     def verify(input, expected_comment_text):
         actual_count, actual_comments = executor.execute(
-            input, page=1, page_size=100)
+            input, offset=0, limit=100)
         actual_comment_text = [c.text for c in actual_comments]
         assert actual_count == len(expected_comment_text)
         assert actual_comment_text == expected_comment_text
diff --git a/server/szurubooru/tests/search/configs/test_post_search_config.py b/server/szurubooru/tests/search/configs/test_post_search_config.py
index ef3cdd9..b42b3f5 100644
--- a/server/szurubooru/tests/search/configs/test_post_search_config.py
+++ b/server/szurubooru/tests/search/configs/test_post_search_config.py
@@ -65,7 +65,7 @@ def auth_executor(executor, user_factory):
 def verify_unpaged(executor):
     def verify(input, expected_post_ids, test_order=False):
         actual_count, actual_posts = executor.execute(
-            input, page=1, page_size=100)
+            input, offset=0, limit=100)
         actual_post_ids = list([p.post_id for p in actual_posts])
         if not test_order:
             actual_post_ids = sorted(actual_post_ids)
@@ -381,7 +381,7 @@ def test_filter_by_safety(
 
 def test_filter_by_invalid_type(executor):
     with pytest.raises(errors.SearchError):
-        executor.execute('type:invalid', page=1, page_size=100)
+        executor.execute('type:invalid', offset=0, limit=100)
 
 
 @pytest.mark.parametrize('input,expected_post_ids', [
@@ -458,7 +458,7 @@ def test_filter_by_image_size(
 
 def test_filter_by_invalid_aspect_ratio(executor):
     with pytest.raises(errors.SearchError):
-        executor.execute('image-ar:1:1:1', page=1, page_size=100)
+        executor.execute('image-ar:1:1:1', offset=0, limit=100)
 
 
 @pytest.mark.parametrize('input,expected_post_ids', [
@@ -706,7 +706,7 @@ def test_own_disliked(
 ])
 def test_someones_score(executor, input):
     with pytest.raises(errors.SearchError):
-        executor.execute(input, page=1, page_size=100)
+        executor.execute(input, offset=0, limit=100)
 
 
 def test_own_fav(
diff --git a/server/szurubooru/tests/search/configs/test_tag_search_config.py b/server/szurubooru/tests/search/configs/test_tag_search_config.py
index 8360943..1dee3e3 100644
--- a/server/szurubooru/tests/search/configs/test_tag_search_config.py
+++ b/server/szurubooru/tests/search/configs/test_tag_search_config.py
@@ -13,7 +13,7 @@ def executor():
 def verify_unpaged(executor):
     def verify(input, expected_tag_names):
         actual_count, actual_tags = executor.execute(
-            input, page=1, page_size=100)
+            input, offset=0, limit=100)
         actual_tag_names = [u.names[0].name for u in actual_tags]
         assert actual_count == len(expected_tag_names)
         assert actual_tag_names == expected_tag_names
@@ -183,7 +183,7 @@ def test_filter_by_post_count(
 ])
 def test_filter_by_invalid_input(executor, input):
     with pytest.raises(errors.SearchError):
-        executor.execute(input, page=1, page_size=100)
+        executor.execute(input, offset=0, limit=100)
 
 
 @pytest.mark.parametrize('input,expected_tag_names', [
diff --git a/server/szurubooru/tests/search/configs/test_user_search_config.py b/server/szurubooru/tests/search/configs/test_user_search_config.py
index f54ba45..6a71211 100644
--- a/server/szurubooru/tests/search/configs/test_user_search_config.py
+++ b/server/szurubooru/tests/search/configs/test_user_search_config.py
@@ -13,7 +13,7 @@ def executor():
 def verify_unpaged(executor):
     def verify(input, expected_user_names):
         actual_count, actual_users = executor.execute(
-            input, page=1, page_size=100)
+            input, offset=0, limit=100)
         actual_user_names = [u.name for u in actual_users]
         assert actual_count == len(expected_user_names)
         assert actual_user_names == expected_user_names
@@ -135,21 +135,23 @@ def test_combining_tokens(
 
 
 @pytest.mark.parametrize(
-    'page,page_size,expected_total_count,expected_user_names', [
-        (1, 1, 2, ['u1']),
-        (2, 1, 2, ['u2']),
-        (3, 1, 2, []),
+    'offset,limit,expected_total_count,expected_user_names', [
         (0, 1, 2, ['u1']),
+        (1, 1, 2, ['u2']),
+        (2, 1, 2, []),
+        (-1, 1, 2, ['u1']),
+        (0, 2, 2, ['u1', 'u2']),
+        (3, 1, 2, []),
         (0, 0, 2, []),
     ])
 def test_paging(
-        executor, user_factory, page, page_size,
+        executor, user_factory, offset, limit,
         expected_total_count, expected_user_names):
     db.session.add(user_factory(name='u1'))
     db.session.add(user_factory(name='u2'))
     db.session.flush()
     actual_count, actual_users = executor.execute(
-        '', page=page, page_size=page_size)
+        '', offset=offset, limit=limit)
     actual_user_names = [u.name for u in actual_users]
     assert actual_count == expected_total_count
     assert actual_user_names == expected_user_names
@@ -222,7 +224,7 @@ def test_random_sort(executor, user_factory):
     db.session.add_all([user3, user1, user2])
     db.session.flush()
     actual_count, actual_users = executor.execute(
-        'sort:random', page=1, page_size=100)
+        'sort:random', offset=0, limit=100)
     actual_user_names = [u.name for u in actual_users]
     assert actual_count == 3
     assert len(actual_user_names) == 3
@@ -251,4 +253,4 @@ def test_random_sort(executor, user_factory):
 ])
 def test_bad_tokens(executor, input, expected_error):
     with pytest.raises(expected_error):
-        executor.execute(input, page=1, page_size=100)
+        executor.execute(input, offset=0, limit=100)