How to Build a Custom 'Recently Viewed Products' Module in Magento 2

How to Build a Custom 'Recently Viewed Products' Module in Magento 2

Want to add a neat "Recently viewed products" box to your Magento 2 store without buying an extension? Cool — I'll walk you through a solid, production-ready approach that balances flexibility and performance. We'll cover the architecture (using observers), client vs server storage, layout and template integration, advanced personalization (limits, retention, category exclusion), and caching considerations so you won't break Full Page Cache (FPC).

What we'll build

  • A small Magento 2 module that captures product views via an observer.
  • Two storage strategies: client-side (localStorage) and server-side (DB table) with a toggleable config.
  • A block + template and layout XML to render a recent-products widget.
  • Options for limiting number of items, retention, and excluding categories.
  • AJAX-friendly rendering to keep FPC intact (best practice).

Why not just use Magento's built-in reports?

Magento does store product views in reporting tables but those are aggregated for analytics and not always ideal for per-user, real-time display. Also relying on the core reports requires more DB joins and is slower. Custom logic gives you full control: per-visitor, instant updates, client-only mode for privacy, and server mode for cross-device persistence.

Module architecture overview

Here's the high level architecture.

  • Observer: Listens to product view action and emits data about the viewed product.
  • Service layer: Decides whether to persist to server (DB) or emit JS event (client localStorage).
  • DB resource model (optional): A small table to store recent views per visitor or per customer.
  • Frontend: A lightweight block + template that renders recent items with an AJAX endpoint to fetch data.
  • Config: Admin options for storage mode, max items, retention days, excluded categories.
  • Cron job (if using server storage): Purges old records according to retention policy.

Key performance principle: do not make the product-view page heavy. Capture events quickly and render recent items asynchronously (AJAX or customer-data) to avoid disabling FPC.

Step 1 — Module skeleton

Create the basic module files. Put them under app/code/Magefine/RecentlyViewed (replace Magefine with your vendor if needed).

registration.php

<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
    ComponentRegistrar::MODULE,
    'Magefine_RecentlyViewed',
    __DIR__
);

etc/module.xml

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Magefine_RecentlyViewed" setup_version="1.0.0" />
</config>

That’s it for the skeleton. Now we add the observer.

Step 2 — Observer to capture product views

We want to listen to product view pages. A reliable event is controller_action_postdispatch_catalog_product_view which fires after the product view controller action runs. It's lightweight and gives access to the request and product. Create etc/frontend/events.xml:

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework/Event/etc/events.xsd">
    <event name="controller_action_postdispatch_catalog_product_view">
        <observer name="magefine_recently_viewed_observer" instance="Magefine\RecentlyViewed\Observer\ProductView" />
    </event>
</config>

Observer class: Observer/ProductView.php

<?php
namespace Magefine\RecentlyViewed\Observer;

use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;

class ProductView implements ObserverInterface
{
    private $logger;
    private $recentService;

    public function __construct(
        LoggerInterface $logger,
        \Magefine\RecentlyViewed\Model\RecentlyViewedService $recentService
    ) {
        $this->logger = $logger;
        $this->recentService = $recentService;
    }

    public function execute(Observer $observer)
    {
        try {
            $controller = $observer->getEvent()->getControllerAction();
            $product = $controller->getRequest()->getParam('id') ? $controller->getRequest()->getParam('id') : null;
            // Better approach is to fetch product model from registry or layout block
            $productModel = null;
            $productBlock = $controller->getLayout()->getBlock('product.info');
            if ($productBlock) {
                $productModel = $productBlock->getProduct();
            }
            if ($productModel) {
                $this->recentService->captureView($productModel);
            }
        } catch (\Exception $e) {
            $this->logger->debug('RecentlyViewed observer error: ' . $e->getMessage());
        }
    }
}

Note: We protect ourselves with try/catch and avoid heavy logic in observer.

Step 3 — RecentlyViewedService: decide local vs server

Create a service class to centralize logic. It reads config to decide storage mode and delegates to either a localStorage JS push (via page config) or a DB write.

<?php
namespace Magefine\RecentlyViewed\Model;

use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Customer\Model\Session as CustomerSession;
use Magento\Store\Model\StoreManagerInterface;

class RecentlyViewedService
{
    const XML_PATH_STORAGE_MODE = 'magefine_recently_viewed/general/storage_mode'; // 'client' or 'server'

    protected $scopeConfig;
    protected $customerSession;
    protected $storeManager;
    protected $serverRepo; // resource for server storage
    protected $jsHelper; // for pushing data to page config

    public function __construct(
        ScopeConfigInterface $scopeConfig,
        CustomerSession $customerSession,
        StoreManagerInterface $storeManager,
        \Magefine\RecentlyViewed\Model\ServerRepository $serverRepo,
        \Magefine\RecentlyViewed\Helper\JsPush $jsHelper
    ) {
        $this->scopeConfig = $scopeConfig;
        $this->customerSession = $customerSession;
        $this->storeManager = $storeManager;
        $this->serverRepo = $serverRepo;
        $this->jsHelper = $jsHelper;
    }

    public function captureView($product)
    {
        $mode = $this->scopeConfig->getValue(self::XML_PATH_STORAGE_MODE);
        $productData = [
            'id' => $product->getId(),
            'sku' => $product->getSku(),
            'name' => $product->getName(),
            'url' => $product->getProductUrl(),
            'image' => $product->getImage(),
            'price' => $product->getFinalPrice(),
        ];

        if ($mode === 'server') {
            $this->serverRepo->saveView($productData, $this->customerSession->getCustomerId());
        } else {
            // client push: inject an inline script via a helper that adds product to localStorage
            $this->jsHelper->pushProductToPage($productData);
        }
    }
}

We use a helper to add a small snippet to page config. That helper will be called very early in page render and only inject minimal JS. The observer simply calls this service so switching modes is easy.

Step 4 — Client-side approach (localStorage)

Client-side storage is privacy-friendly and fast. Good for anonymous visitors and avoids DB overhead. However, it is device-specific (not shared between devices) and limited by browser storage.

Strategy:

  • Observer triggers a small inline JSON payload to be available in the page HTML configuration.
  • Frontend JS listens and writes to localStorage. Use a dedicated key like magefine_recently_viewed.
  • Render widget by reading localStorage (via JS) and calling a small template to render items client-side. Alternatively, the block can include an empty placeholder that JS fills after DOM load.

Helper example: Helper/JsPush.php

<?php
namespace Magefine\RecentlyViewed\Helper;

use Magento\Framework\View\Page\Config as PageConfig;

class JsPush
{
    private $pageConfig;

    public function __construct(PageConfig $pageConfig)
    {
        $this->pageConfig = $pageConfig;
    }

    public function pushProductToPage(array $productData)
    {
        // small array on window.MagefineRecentlyViewed to avoid inline scripts preference
        $json = json_encode($productData);
        $this->pageConfig->addRemotePageAsset('', 'js', ['attributes' => ['data-magefine-recent' => $json]]);
        // Simpler: inject inline script
        $this->pageConfig->addBodyClass('mf-rv-inject');
        $this->pageConfig->addHeadElement('<script>window.__mfRecentlyViewed = window.__mfRecentlyViewed || [] ; window.__mfRecentlyViewed.push(' . $json . ');</script>');
    }
}

Note: adding inline scripts may be restricted by Content Security Policy — prefer customer-data or adding data attributes to markup and a small external JS file to read them.

Frontend JS (view/frontend/web/js/recently-viewed.js):

define(['jquery'], function ($) {
    'use strict';
    var STORAGE_KEY = 'magefine_recently_viewed';
    var MAX_ITEMS = 12; // could be read from config via ajax

    function read() {
        try {
            return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
        } catch (e) {
            return [];
        }
    }
    function write(arr) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
    }

    function push(item) {
        var list = read();
        // dedupe by id
        list = list.filter(function (i) { return i.id != item.id; });
        list.unshift(item);
        if (list.length > MAX_ITEMS) list = list.slice(0, MAX_ITEMS);
        write(list);
    }

    $(function () {
        if (window.__mfRecentlyViewed && window.__mfRecentlyViewed.length) {
            window.__mfRecentlyViewed.forEach(function (p) { push(p); });
        }

        // Expose a method to fill the widget
        window.MagefineRecentlyViewed = window.MagefineRecentlyViewed || {};
        window.MagefineRecentlyViewed.get = read;
        window.MagefineRecentlyViewed.push = push;
    });
});

Rendering: include a placeholder block that JS fills with an HTML snippet (templating via simple string concat or use knockout/component). Doing it client-side avoids cache issues.

Step 5 — Server-side approach (DB)

Server-side persistence lets logged-in customers see recent products across devices. We'll create a tiny table to store product_id, customer_id (nullable for guests via visitor_id or session hash), store_id, created_at.

db_schema.xml (for declarative schema):

<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="magefine_recently_viewed" resource="default" engine="innodb" comment="Magefine Recently Viewed">
        <column xsi:type="int" name="id" unsigned="true" nullable="false" identity="true" />
        <column xsi:type="int" name="product_id" nullable="false" unsigned="true" />
        <column xsi:type="int" name="customer_id" nullable="true" unsigned="true" />
        <column xsi:type="varchar" name="visitor_session" nullable="true" length="255" />
        <column xsi:type="smallint" name="store_id" nullable="false" unsigned="true" default="0" />
        <column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP" />
        <constraint referenceId="PRIMARY" xsi:type="primary" />
    </table>
</schema>

Repository model: Model/ServerRepository.php should provide saveView(array $productData, $customerId = null) and getRecentForCustomer($customerId, $limit) or getRecentForSession($sessionHash, $limit).

<?php
namespace Magefine\RecentlyViewed\Model;

use Magento\Framework\Stdlib\DateTime\DateTime;

class ServerRepository
{
    protected $resource;
    protected $connection;
    protected $tableName;
    protected $date;

    public function __construct(
        \Magento\Framework\App\ResourceConnection $resource,
        DateTime $date
    ) {
        $this->resource = $resource;
        $this->connection = $resource->getConnection();
        $this->tableName = $resource->getTableName('magefine_recently_viewed');
        $this->date = $date;
    }

    public function saveView(array $productData, $customerId = null)
    {
        $now = $this->date->gmtDate();
        // Insert a new row. Optionally, dedupe first to avoid duplicate consecutive rows
        $this->connection->insert($this->tableName, [
            'product_id' => (int)$productData['id'],
            'customer_id' => $customerId ? (int)$customerId : null,
            'visitor_session' => session_id(),
            'store_id' => (int)$productData['store_id'] ?? 1,
            'created_at' => $now
        ]);
    }

    public function getRecentForCustomer($customerId, $limit = 10)
    {
        $select = $this->connection->select()
            ->from($this->tableName, ['product_id'])
            ->where('customer_id = ?', $customerId)
            ->order('created_at DESC')
            ->limit((int)$limit);
        return $this->connection->fetchCol($select);
    }
}

Important: keep DB operations fast and write-only in the observer. Reads for rendering should be done from a block or via AJAX controller, not in the observer.

Step 6 — Rendering the widget: avoid FPC issues

Magento 2 uses Full Page Cache which caches HTML output. If you render dynamic per-user content server-side, you risk serving the same recent list to multiple users. There are a few techniques to handle dynamic blocks safely:

  • Make the block uncached (cacheable="false"). Simple but decreases FPC hit rate.
  • Use AJAX to fetch the personalized list after page load. Best practice.
  • Use Magento customer-data sections to store and update small JSON pieces for private content.
  • For logged-in users, use ESI/Edge Side Includes with Varnish (advanced).

I recommend AJAX or customer-data. We'll show the AJAX approach because it's easy and consistent with both client and server storage modes.

Layout XML: view/frontend/layout/default.xml (add widget placeholder on all pages) or add a block to product listing templates as needed.

<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="sidebar.additional">
            <block class="Magefine\RecentlyViewed\Block\Widget" name="magefine.recently.viewed" template="Magefine_RecentlyViewed::widget.phtml" />
        </referenceContainer>
    </body>
</page>

Block class: Block/Widget.php

<?php
namespace Magefine\RecentlyViewed\Block;

use Magento\Framework\View\Element\Template;

class Widget extends Template
{
    protected $urlBuilder;

    public function __construct(Template\Context $context, array $data = [])
    {
        parent::__construct($context, $data);
        $this->urlBuilder = $context->getUrlBuilder();
    }

    public function getAjaxUrl()
    {
        return $this->getUrl('magefine_recently/view/list');
    }
}

template view/frontend/templates/widget.phtml (placeholder)

<div id="magefine-recently-viewed" class="mf-recently-widget" data-ajax-url="<?= $block->escapeUrl($block->getAjaxUrl()) ?>">
    <div class="mf-loading">Loading recently viewed products...
</div> <script type="text/x-magento-init"> { "#magefine-recently-viewed": { "Magefine_RecentlyViewed/js/widget-init": {} } } </script>

Frontend widget JS: view/frontend/web/js/widget-init.js

define(['jquery','mage/url'], function ($, urlBuilder) {
    'use strict';
    return function (config, element) {
        var $el = $(element);
        var ajaxUrl = $el.data('ajax-url');

        function renderList(items) {
            if (!items || items.length === 0) {
                $el.html('<div class="mf-empty">No recently viewed products
'); return; } var html = '<ul class="mf-recent-list">'; items.forEach(function (p) { html += '<li><a href="' + p.url + '"><img src="' + p.image + '" alt="' + p.name + '" width="50"/> ' + p.name + '</a></li>'; }); html += '</ul>'; $el.html(html); } // If client mode if (window.MagefineRecentlyViewed && typeof window.MagefineRecentlyViewed.get === 'function') { renderList(window.MagefineRecentlyViewed.get()); // fallback to AJAX if empty and server mode } else { $.getJSON(ajaxUrl).done(function (data) { renderList(data.items || []); }).fail(function () { $el.html('<div class="mf-error">Could not load recently viewed items</div>'); }); } }; });

AJAX controller: Controller/View/List.php should read mode and return JSON of product data. If server mode, query the server repository. If client mode, return empty and let JS populate from localStorage.

Step 7 — Personalization options

Let's add common personalization options via system configuration (etc/adminhtml/system.xml):

  • Storage Mode: client or server
  • Max Items: integer
  • Retention (days): integer (only for server mode)
  • Excluded Category IDs: comma-separated list

In code, read these values from scopeConfig: when saving server rows or when pushing client payload, skip products belonging to excluded categories. Implement this check in your RecentlyViewedService to keep logic centralized.

Example for exclusion check:

public function isExcluded($product)
{
    $excluded = $this->scopeConfig->getValue('magefine_recently_viewed/general/exclude_categories'); // "12,34"
    if (!$excluded) return false;
    $ids = array_filter(array_map('trim', explode(',', $excluded)));
    $productCats = $product->getCategoryIds();
    return count(array_intersect($ids, $productCats)) > 0;
}

Limit items: Widget JS and server repository both apply the limit when returning items. Always apply limits at read-time to reduce payload size.

Retention: For server-side storage, add a cron job (etc/crontab.xml) that runs daily and deletes rows older than retention days. This keeps the table compact.

DELETE FROM magefine_recently_viewed WHERE created_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :days DAY)

Step 8 — Performance and cache management

Performance is the trickiest part. Here are practical rules:

  • Do writes in observer as small as possible — avoid loading heavy models or collections.
  • Avoid disabling FPC. Use AJAX or Magento customer-data to inject private content.
  • If using server-side mode for logged-in users, fetch recent product IDs from DB and then load product collection with only needed attributes (name, small_image, url_key, price) and using addAttributeToSelect(['name','small_image','price']) to limit overhead.
  • Cache product image URLs and prices where possible; consider using a lightweight JSON response that the frontend caches for a short TTL in sessionStorage/localStorage to avoid repeated AJAX calls on each page view.
  • For Varnish, prefer edge side includes (ESI) only if you're comfortable. For most stores, AJAX is simpler and safer.
  • If you must render server-side and want to maintain FPC, make the block cacheable but include personalized placeholders via javascript or private customer-data sections.

Example: optimized server read (Repository method to get product collection):

public function getProductCollectionByIds(array $ids, $storeId, $limit)
{
    $collection = $this->productCollectionFactory->create();
    $collection->addAttributeToSelect(['name','small_image','price'])
        ->addAttributeToFilter('entity_id', ['in' => $ids])
        ->setStoreId($storeId)
        ->setPageSize($limit);

    return $collection;
}

Also use full page cache-friendly HTTP headers for the AJAX response — it can be cached per user or per session for a short period if needed.

Step 9 — Edge cases and tips

  • Guest users: server mode can store a visitor_session token (hash of PHP session id). Keep an eye on session affinity if you have load-balanced servers.
  • GDPR: If you store personal data server-side, make sure it's compliant with privacy rules and can be deleted upon user request.
  • Image URLs: to ensure correct image URLs in AJAX, use \Magento\Catalog\Helper\Image or create a small view helper to render full URL. Avoid loading the whole product model in the observer.
  • Deduplication: When saving to DB, you may want to dedupe repeated views within short periods to avoid large tables; use SELECT to check latest view timestamp for that customer/product pair.

Step 10 — Deployment checklist

Before putting this into production, check the following:

  1. Module runs in developer mode and logs any errors to a dedicated log file.
  2. AJAX endpoint returns lean JSON and respects block limits.
  3. Retention cron is configured and test-deleted old rows as expected.
  4. If using localStorage, confirm it doesn’t overflow — limit items and truncate as needed.
  5. Test across mobile/desktop and logged-in/guest flows.
  6. Security: sanitize any data passed to the frontend and avoid exposing internal IDs where not needed.

Full code snippets cheat sheet

To make your life easier, here are the core snippets together:

  • Observer listens to controller_action_postdispatch_catalog_product_view.
  • Service decides client/server and calls helper or repository.
  • Helper injects minimal JS payload (or better: uses customer-data section).
  • Block + template provide placeholder and AJAX URL.
  • Frontend JS fills placeholder from localStorage or AJAX result.

If you want, I can generate a ready-to-install module zip with files and examples. For a production-ready extension sold on Magefine, you’d also add unit tests, integration tests, and clear admin UI for configuring options.

Why Magefine-friendly and SEO tips

Because this article targets magefine.com, notice a few SEO-friendly points you should use when publishing:

  • Use a concise URL key like custom-recently-viewed-products-magento-2 (we'll generate that below).
  • Use meta title and description that include "Magento 2", "recently viewed", and "Magefine" for trust signals.
  • Include code snippets and clear step-by-step headings (great for long-tail search queries).
  • Offer downloadable sample module or Github link (if you provide one) so readers can get started faster.

Summary

Building a custom "Recently Viewed" module in Magento 2 is a great way to learn about observers, DI, layout XML, and frontend integration. You can choose between client-side storage (fast and privacy-friendly) and server-side storage (persistent across devices). The recommended production setup is:

  1. Capture product views via observer — minimal logic.
  2. Store either via localStorage (client) or write lightweight rows to DB (server).
  3. Render widget asynchronously by AJAX or customer-data to preserve FPC.
  4. Respect personalization settings: limit, retention, exclude categories.
  5. Optimize reads by limiting attributes and caching where safe.

If you'd like to, I can produce the full module file tree and a sample admin UI next — tell me whether you prefer the client-only localStorage route or server-side persistence for logged-in users.

Happy coding — and ping me if you want the full module zipped up for quick deployment on a store hosted at Magefine.

-- Your friendly Magento dev