Velo by Wix: Custom pagination with unique URLs

the illustration depicting a pagination component on the web page

The Wix Blog App has built-in pagination with links. Each button on the pagination element has a unique link. Links are required for good SEO.

I have reproduced the pagination element that uses unique URLs and creates my own implementation. (Lots of code 😳)

For that, I use a wix-router on the server part and I generating custom pagination by Repeater on the front-end part.

So pagination is a difficult component. I share my solution I hope it will be helpful (interesting) to take a look at how it works.

Live Demo

Code

Route logic for pagination.

We create a router page named custom-blog-page. With wix-router APIs, we can dynamically return any data to the router page from the server hook.

I use regular Wix Blog databases. These collections are created when you add a Wix Blog App to your site. In the router, we are able to use any kind of database collection.

In the router, we have four URLs types that we will handle:

/custom-blog                       - First page for all post categories
/custom-blog/:pageNumber           - Pagination page for all post categories
/custom-blog/:category             - First page for specific post categories
/custom-blog/:category/:pageNumber - Pagination page for specific post categories

In the router hook, we are parsing a request path. If request params are valid then we receive data from collections and return it to the page.

backend/routers.js
import wixData from 'wix-data';
import { ok, redirect, WixRouterSitemapEntry } from 'wix-router';
// The 'url-join' external npm library.
// It has to be installed with npm Package Manager before writing a code.
// More: https://support.wix.com/en/article/velo-working-with-npm-packages
import urlJoin from 'url-join';

const hasContent = (val) => typeof val === 'string' && val.trim() !== '';
const isNumeric = (val) => hasContent(val) && /^[\d]+$/.test(val);
const parseNumber = (val) => ~~Math.abs(+val);

// I use regular Wix Blog databases.
// These collections are created when you add a Wix Blog app in your site.
// More: https://support.wix.com/en/article/wix-blog-creating-your-blog
const getCategories = () => {
  return wixData
    .query('Blog/Categories')
    .find()
    .then((result) => result.items);
};

const getCategory = (label) => {
  return wixData
    .query('Blog/Categories')
    .eq('label', label)
    .limit(1)
    .find()
    .then((reslut) => reslut.items[0]);
};

const getPosts = async (pageSize, skipPages, categoryId = null) => {
  let dataQuery = wixData.query('Blog/Posts');

  if (hasContent(categoryId)) {
    dataQuery = dataQuery.hasAll('categories', categoryId);
  }

  return dataQuery
    .skip(skipPages)
    .limit(pageSize)
    .find();
};

const getParams = async (path) => {
  const [one, two] = path.map((i) => i.toLowerCase());

  if (path.length === 1) {
    if (one === '') {
      return {
        page: 0,
        label: '',
      };
    }

    if (isNumeric(one)) {
      return {
        page: parseNumber(one),
        label: '',
      };
    }

    const category = await getCategory(one);

    if (typeof category !== 'undefined') {
      return {
        page: 0,
        categoryId: category._id,
        label: category.label,
      };
    }
  }

  if (path.length === 2 && isNumeric(two)) {
    const category = await getCategory(one);

    if (typeof category !== 'undefined') {
      return {
        page: parseNumber(two),
        categoryId: category._id,
        label: category.label,
      };
    }
  }

  return { hasError: true };
};

/**
 * Router hook
 *
 * @param {wix_router.WixRouterRequest} request
 */
export async function custom_blog_Router({ path, baseUrl, prefix }) {
  const params = await getParams(path);

  // Invalid params. Redirect to a base route URL.
  if (params.hasError) {
    return redirect(urlJoin(baseUrl, prefix), '301');
  }

  // Page size. It controls how many posts we show on the page.
  const pageSize = 2;
  const skip = (params.page === 0 ? 0 : params.page - 1) * pageSize;

  const postsData = await getPosts(
    pageSize,
    skip,
    params.categoryId,
  );

  // Returns a router page data to client
  return ok('custom-blog-page', {
    pageSize,
    posts: postsData.items,
    currentPage: postsData.currentPage,
    totalCount: postsData.totalCount,
    totalPages: postsData.totalPages,
    label: params.label,
  });
}

/**
 * Generate sitemaps
 * https://www.wix.com/velo/reference/wix-router/sitemap
 *
 * @param {wix_router.WixRouterSitemapRequest} sitemapRequest
 * @returns {Promise<wix_router.WixRouterSitemapEntry[]>}
 */
export async function custom_blog_SiteMap(sitemapRequest) {
  const categories = await getCategories();

  return categories.map((i) => {
    const entry = new WixRouterSitemapEntry(i.label);

    return Object.assign(entry, {
      title: i.label,
      pageName: i.label,
      url: urlJoin('/', sitemapRequest.prefix, i.label),
    });
  });
}

Route page with blog posts repeater's and custom pagination.

On the router page, we are able to get data that we return from the router hook. We use route data to create custom pagination with the Repeater element.

Custom Blog Page (code):
import { getRouterData } from 'wix-window';
import { prefix } from 'wix-location';
import urlJoin from 'url-join';

import { paginate } from 'public/paginate';

// Join paths to URL prefix
const join = (...paths) => urlJoin('/', prefix, ...paths);

$w.onReady(function () {
  // Here we get router data that we return from "backend/routers.js"
  const {
    posts,
    currentPage,
    totalCount,
    pageSize,
    label,
  } = getRouterData();

  // The function for generating repeater data.
  // It's our custom repeater source.
  const { data } = paginate({
    totalCount,
    currentPage,
    maxPages: 4,
    pageSize,
  });

  // Repeater for posts
  $w('#repeaterPosts').data = posts;
  $w('#repeaterPosts').forEachItem(($item, itemData) => {
    $item('#textTitle').text = itemData.title;
  });

  // Repeater for custom pagination
  $w('#repeaterPagination').data = data;
  $w('#repeaterPagination').forEachItem(($item, itemData) => {
    $item('#button1').label = itemData.label;

    if (itemData.isActive) {
      $item('#button1').link = join(label, String(itemData.number));
    } else {
      $item('#button1').disable();
    }
  });

  // Build a links for categories
  $w('#buttonLinkAll').link = join('');
  $w('#buttonLinkCss').link = join('css');
  $w('#buttonLinkHtml').link = join('html');
  $w('#buttonLinkJs').link = join('js');
});

Pagination logic.

This file contains logic for generating a repeater data array. It's the most difficult part of custom pagination for me, the logic of calculating the position of repeater items.

public/paginate.js
/**
 * @typedef {{
 * totalCount: number;
 * currentPage: number;
 * pageSize: number;
 * maxPages: number;
 * }} Params
 *
 * @typedef {{
 * _id: string;
 * label: string;
 * number: number;
 * isActive: boolean;
 * }} Data
 *
 * @param {Params} params
 * @returns {{ data: Data[] }}
 */
export const paginate = ({
  totalCount,
  currentPage,
  pageSize,
  maxPages,
}) => {
  const totalPages = Math.ceil(totalCount / pageSize);

  let startPage = 1;
  let endPage = totalPages;

  if (currentPage > totalPages) {
    currentPage = totalPages;
  }

  if (totalPages > maxPages) {
    const maxPagesBeforeCurrentPage = Math.floor(maxPages / 2);
    const maxPagesAfterCurrentPage = Math.ceil(maxPages / 2);

    if (currentPage <= maxPagesBeforeCurrentPage) {
      endPage = maxPages;
    } else if (currentPage + maxPagesAfterCurrentPage >= totalPages) {
      startPage = totalPages - maxPages + 1;
      endPage = totalPages;
    } else {
      startPage = currentPage - maxPagesBeforeCurrentPage;
      endPage = currentPage + maxPagesAfterCurrentPage;
    }
  }

  const length = (endPage + 1) - startPage;

  /** @type {Data[]} */
  const data = Array.from({ length },
    (_, index) => {
      const number = startPage + index;
      const id = String(number);

      return {
        _id: id,
        label: id,
        number,
        isActive: number !== (currentPage + 1),
      };
    },
  );

  data.unshift({
    _id: 'first',
    label: '<<',
    number: 1,
    isActive: currentPage > 0,
  }, {
    _id: 'prev',
    label: '<',
    number: currentPage,
    isActive: currentPage > 0,
  });

  data.push({
    _id: 'next',
    label: '>',
    number: currentPage + 2,
    isActive: (currentPage + 1) < totalPages,
  }, {
    _id: 'last',
    label: '>>',
    number: totalPages,
    isActive: (currentPage + 1) < totalPages,
  });

  return {
    data,
  };
};

If you have a question then welcome to discussion on Velo forum.

Resources

Posts