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 approche that balances flexibility and performance. Nous allons cover the architecture (using observateurs), 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 observateur.
  • 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 rapports?

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

Module architecture aperçu

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 visiteur or per client.
  • 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 selon retention policy.

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

Step 1 — Module skeleton

Create the basic module fichiers. 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 observateur.

Step 2 — Observer to capture product views

We want to listen to product view pages. A reliable event is contrôleur_action_postdiscorrectif_catalog_product_view which fires after the product view contrôleur action runs. C'est 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="contrôleur_action_postdiscorrectif_catalog_product_view">
        <observateur name="magefine_recently_viewed_observateur" 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 fonction __construct(
        LoggerInterface $logger,
        \Magefine\RecentlyViewed\Model\RecentlyViewedService $recentService
    ) {
        $this->logger = $logger;
        $this->recentService = $recentService;
    }

    public fonction execute(Observer $observateur)
    {
        try {
            $contrôleur = $observateur->getEvent()->getControllerAction();
            $product = $contrôleur->getRequest()->getParam('id') ? $contrôleur->getRequest()->getParam('id') : null;
            // Better approche is to fetch product model from registry or layout block
            $productModel = null;
            $productBlock = $contrôleur->getLayout()->getBlock('product.info');
            if ($productBlock) {
                $productModel = $productBlock->getProduct();
            }
            if ($productModel) {
                $this->recentService->captureView($productModel);
            }
        } catch (\Exception $e) {
            $this->logger->débogage('RecentlyViewed observateur erreur: ' . $e->getMessage());
        }
    }
}

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

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 $clientSession;
    protected $storeManager;
    protected $serverRepo; // resource for server storage
    protected $jsHelper; // for pushing data to page config

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

    public fonction 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(),
            'prix' => $product->getFinalPrice(),
        ];

        if ($mode === 'server') {
            $this->serverRepo->saveView($productData, $this->clientSession->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 sera called very early in page render and only inject minimal JS. The observateur simply calls this service so switching modes is easy.

Step 4 — Client-side approche (localStorage)

Client-side storage is privacy-friendly and fast. Good for anonymous visiteurs and avoids DB overhead. Cependant, it is device-specific (not shared between devices) and limited by bligneser 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 clé 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 exemple: Helper/JsPush.php

<?php
namespace Magefine\RecentlyViewed\Helper;

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

class JsPush
{
    private $pageConfig;

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

    public fonction pushProductToPage(tableau $productData)
    {
        // small tableau 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 peut être restricted by Content Security Policy — prefer client-data or adding data attributes to markup and a small external JS fichier to read them.

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

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

    fonction read() {
        try {
            return JSON.parse(localStorage.getItem(STORAGE_KEY)) || [];
        } catch (e) {
            return [];
        }
    }
    fonction write(arr) {
        localStorage.setItem(STORAGE_KEY, JSON.chaîneify(arr));
    }

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

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

        // Expose a méthode 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 chaîne concat or use knockout/composant). Doing it client-side avoids cache problèmes.

Step 5 — Server-side approche (DB)

Server-side persistence lets logged-in clients see recent products across devices. Nous allons create a tiny table to store product_id, client_id (nullable for guests via visiteur_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">
        <colonne xsi:type="int" name="id" unsigned="true" nullable="false" identity="true" />
        <colonne xsi:type="int" name="product_id" nullable="false" unsigned="true" />
        <colonne xsi:type="int" name="client_id" nullable="true" unsigned="true" />
        <colonne xsi:type="varchar" name="visiteur_session" nullable="true" length="255" />
        <colonne xsi:type="smallint" name="store_id" nullable="false" unsigned="true" default="0" />
        <colonne 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(tableau $productData, $clientId = null) and getRecentForCustomer($clientId, $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 fonction __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 fonction saveView(tableau $productData, $clientId = null)
    {
        $now = $this->date->gmtDate();
        // Insert a new ligne. Optionally, dedupe first to avoid duplicate consecutive lignes
        $this->connection->insert($this->tableName, [
            'product_id' => (int)$productData['id'],
            'client_id' => $clientId ? (int)$clientId : null,
            'visiteur_session' => session_id(),
            'store_id' => (int)$productData['store_id'] ?? 1,
            'created_at' => $now
        ]);
    }

    public fonction getRecentForCustomer($clientId, $limit = 10)
    {
        $select = $this->connection->select()
            ->from($this->tableName, ['product_id'])
            ->where('client_id = ?', $clientId)
            ->commande('created_at DESC')
            ->limit((int)$limit);
        return $this->connection->fetchCol($select);
    }
}

Important: keep DB operations fast and write-only in the observateur. Reads for rendering devrait être done from a block or via AJAX contrôleur, not in the observateur.

Step 6 — Rendering the widget: avoid FPC problèmes

Magento 2 uses Full Page Cache which caches HTML output. Si vous render dynamic per-utilisateur contenu server-side, you risk serving the same recent list to mulconseille utilisateurs. Il y a 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 client-data sections to store and update small JSON pieces for private contenu.
  • For logged-in utilisateurs, use ESI/Edge Side Includes with Varnish (advanced).

I recommend AJAX or client-data. Nous allons show the AJAX approche 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 liste de produits 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">
            <classe de bloc="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 fonction __construct(Template\Context $context, tableau $data = [])
    {
        parent::__construct($context, $data);
        $this->urlBuilder = $context->getUrlBuilder();
    }

    public fonction 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'], fonction ($, urlBuilder) {
    'use strict';
    return fonction (config, element) {
        var $el = $(element);
        var ajaxUrl = $el.data('ajax-url');

        fonction 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(fonction (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 === 'fonction') { renderList(window.MagefineRecentlyViewed.get()); // fallback to AJAX if empty and server mode } else { $.getJSON(ajaxUrl).done(fonction (data) { renderList(data.items || []); }).fail(fonction () { $el.html('<div class="mf-erreur">Could not load recently viewed items</div>'); }); } }; });

AJAX contrôleur: 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 valeurs from scopeConfig: when saving server lignes 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 fonction isExcluded($product)
{
    $excluded = $this->scopeConfig->getValue('magefine_recently_viewed/general/exclude_categories'); // "12,34"
    if (!$excluded) return false;
    $ids = tableau_filtre(tableau_map('trim', explode(',', $excluded)));
    $productCats = $product->getCategoryIds();
    return count(tableau_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 tâche cron (etc/crontab.xml) that runs daily and deletes lignes 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 gestion du cache

Performance is the trickiest part. Voici practical rules:

  • Do writes in observateur as small as possible — avoid loading heavy models or collections.
  • Avoid disabling FPC. Use AJAX or Magento client-data to inject private contenu.
  • If using server-side mode for logged-in utilisateurs, fetch recent product IDs from DB and then load product collection with only needed attributes (name, small_image, url_clé, prix) and using addAttributeToSelect(['name','small_image','prix']) to limit overhead.
  • Cache product image URLs and prixs 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.
  • Si vous must render server-side and want to maintain FPC, make the block cacheable but include personalized placeholders via javascript or private client-data sections.

Example: optimized server read (Repository méthode to get product collection):

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

    return $collection;
}

Also use cache pleine page-friendly HTTP headers for the AJAX response — it peut être cached per utilisateur or per session for a short period if needed.

Step 9 — Edge cases and conseils

  • Guest utilisateurs: server mode can store a visiteur_session token (hash of PHP session id). Keep an eye on session affinity if you have load-balanced servers.
  • GDPR: Si vous store personal data server-side, make sure it's compliant with privacy rules and peut être deleted upon utilisateur 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 observateur.
  • 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 client/product pair.

Step 10 — Deployment checklist

Avant putting this into production, check les éléments suivants:

  1. Module runs in développeur mode and logs any erreurs to a dedicated log fichier.
  2. AJAX endpoint returns lean JSON and respects block limits.
  3. Retention cron is configured and test-deleted old lignes 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 contrôleur_action_postdiscorrectif_catalog_product_view.
  • Service decides client/server and calls helper or repository.
  • Helper injects minimal JS payload (or better: uses client-data section).
  • Block + template provide placeholder and AJAX URL.
  • Frontend JS fills placeholder from localStorage or AJAX result.

Si vous want, I can generate a ready-to-install module zip with fichiers and exemples. For a production-ready extension sold on Magefine, you’d also add test unitaires, test d'intégrations, and clear admin UI for configuring options.

Why Magefine-friendly and SEO conseils

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

  • Use a concise URL clé 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 étape-by-étape headings (great for long-tail recherche queries).
  • Offer downloadable sample module or Github link (if you provide one) so readers can get started faster.

Résumé

Building a custom "Recently Viewed" module in Magento 2 is a great way to learn about observateurs, DI, layout XML, and frontend integration. Vous pouvez 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 observateur — minimal logic.
  2. Store either via localStorage (client) or write lightweight lignes to DB (server).
  3. Render widget asynchronously by AJAX or client-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 fichier tree and a sample admin UI next — tell me whether you prefer the client-only localStorage route or server-side persistence for logged-in utilisateurs.

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

-- Your friendly Magento dev