How to Implement a Custom Product Tabs Module in Magento 2

Why add custom product tabs in Magento 2?
Think of product pages like a conversation with your customer. Tabs help you keep that conversation organized: technical specs in one place, size guides in another, and rich user manuals or reviews elsewhere. Building a custom tabs module in Magento 2 gives you control, performance and the ability to hook into product attributes so non-developers can edit content without touching code.
What you'll build in this post
In plain terms, we'll create a small Magento 2 module that:
- Registers a new module and injects a block into the product page layout.
- Reads a product attribute containing JSON for tabs (so admins can edit tabs without code).
- Outputs accessible, SEO-friendly tab markup and uses a tiny JS switcher.
- Shows performance best practices for cacheability and lazy loading.
- Explains how to package and sell this as a premium extension (with a link to a recommended business model).
Module architecture and file structure
Keep the module structure simple and predictable. The recommended folder layout is:
app/code/Magefine/ProductTabs/
├── etc/
│ ├── module.xml
│ ├── frontend/di.xml
│ └── frontend/layout/catalog_product_view.xml
├── registration.php
├── composer.json
├── Block/
│ └── Tabs.php
├── view/frontend/templates/product/tabs.phtml
├── Setup/InstallData.php <-- (or UpgradeData for upgrades)
└── etc/acl.xml (if you add admin UI later)
Step 1 — Basic module registration
Create registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'Magefine_ProductTabs',
__DIR__
);
Create 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_ProductTabs" setup_version="1.0.0"/>
</config>
Step 2 — Add the block to the product page via layout XML
We inject our tabs inside the product info details block (this keeps things consistent with modern themes and Magento default markup). Create etc/frontend/layout/catalog_product_view.xml:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceBlock name="product.info.details">
<block class="Magefine\ProductTabs\Block\Tabs" name="product.custom.tabs" template="Magefine_ProductTabs::product/tabs.phtml" after="-"/>
</referenceBlock>
</body>
</page>
Why product.info.details? Many themes expect product details (description, details, extra tabs) in that area. Using this reference keeps compatibility and ensures our tabs appear with the rest of product content.
Step 3 — The Block: get product and parse attributes
Create Block/Tabs.php. The block will be cache-friendly, read a product attribute (JSON), and return a simple array of tabs. The attribute approach lets admins edit tabs without code: add JSON like [{"title":"Specs","content":"..."},{"title":"Manual","content":"..."}].
<?php
namespace Magefine\ProductTabs\Block;
use Magento\Framework\View\Element\Template;
use Magento\Framework\Registry;
class Tabs extends Template
{
protected $registry;
public function __construct(Template\Context $context, Registry $registry, array $data = [])
{
$this->registry = $registry;
parent::__construct($context, $data);
}
/**
* Get current product
*
* @return \Magento\Catalog\Model\Product|null
*/
public function getProduct()
{
return $this->registry->registry('current_product');
}
/**
* Return array of tabs
*
* @return array
*/
public function getTabs()
{
$product = $this->getProduct();
if (!$product) {
return [];
}
$json = $product->getData('custom_tabs_json'); // attribute storing JSON
if (!$json) {
return [];
}
$tabs = @json_decode($json, true);
if (!is_array($tabs)) {
return [];
}
// sanitize and keep safe values
$result = [];
foreach ($tabs as $tab) {
if (empty($tab['title']) || empty($tab['content'])) {
continue;
}
$result[] = [
'title' => $this->escapeHtml($tab['title']),
'content' => $tab['content'] // we'll escape in template carefully
];
}
return $result;
}
/**
* Improve cacheability: include product id and updated_at in cache key
*/
public function getCacheKeyInfo()
{
$product = $this->getProduct();
$productId = $product ? $product->getId() : 'none';
$updated = $product ? $product->getUpdatedAt() : 'none';
return [
'PRODUCT_TABS',
$productId,
$updated
];
}
public function getCacheLifetime()
{
return 3600; // 1 hour cache; adjust per needs
}
}
Step 4 — Template: output accessible, SEO-friendly tabs
Create view/frontend/templates/product/tabs.phtml. Use simple markup optimized for search engines: give each tab a heading (h2/h3) and content in semantic containers. We avoid heavy JS; tabs are progressive: even if JS disabled, all content is visible below the triggers (good for SEO).
<?php
/** @var $block Magefine\ProductTabs\Block\Tabs */
$tabs = $block->getTabs();
if (empty($tabs)) {
return;
}
?>
<div class="magefine-product-tabs" data-magefine-tabs>
<ul class="mf-tabs-list" role="tablist">
<?php foreach ($tabs as $i => $t): ?>
<li role="presentation">
<button type="button" role="tab" aria-selected="<?= $i === 0 ? 'true' : 'false' ?>" aria-controls="mf-tab-<?= $i ?>" id="mf-tab-btn-<?= $i ?>"><?= $t['title'] ?></button>
</li>
<?php endforeach; ?>
</ul>
<div class="mf-tabs-panels">
<?php foreach ($tabs as $i => $t): ?>
<section id="mf-tab-<?= $i ?>" role="tabpanel" aria-labelledby="mf-tab-btn-<?= $i ?>" aria-hidden="<?= $i === 0 ? 'false' : 'true' ?>" class="mf-tab-panel">
<h3 class="mf-tab-title"><?= $t['title'] ?></h3>
<div class="mf-tab-content"><?php echo $block->escapeHtml($t['content']); ?></div>
</section>
<?php endforeach; ?>
</div>
</div>
<script>(function(){
var root = document.querySelector('[data-magefine-tabs]');
if(!root) return;
var tabs = root.querySelectorAll('[role="tab"]');
var panels = root.querySelectorAll('[role="tabpanel"]');
for(var i=0;i<tabs.length;i++){(function(i){
tabs[i].addEventListener('click', function(){
for(var j=0;j<tabs.length;j++){
tabs[j].setAttribute('aria-selected', 'false');
panels[j].setAttribute('aria-hidden', 'true');
}
tabs[i].setAttribute('aria-selected','true');
panels[i].setAttribute('aria-hidden','false');
});
})(i);}
})();</script>
Note: we used escapeHtml for title and content in the block/template. If you need to allow HTML in content (e.g., rich descriptions), you must carefully sanitize or allow limited HTML via a whitelist. Otherwise, store sanitized HTML in the attribute (admins edit via WYSIWYG then sanitize on save).
Step 5 — Add the product attribute for non-developers
You want admins to be able to edit tabs without code. The simplest approach is adding a product attribute called custom_tabs_json and let admins paste JSON. You can create it using InstallData or via a setup script. Example for InstallData:
<?php
namespace Magefine\ProductTabs\Setup;
use Magento\Eav\Setup\EavSetup;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\InstallDataInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\ModuleDataSetupInterface;
class InstallData implements InstallDataInterface
{
private $eavSetupFactory;
public function __construct(EavSetupFactory $eavSetupFactory)
{
$this->eavSetupFactory = $eavSetupFactory;
}
public function install(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
{
$eavSetup = $this->eavSetupFactory->create(['setup' => $setup]);
$eavSetup->addAttribute(
\Magento\Catalog\Model\Product::ENTITY,
'custom_tabs_json',
[
'type' => 'text',
'backend' => '',
'frontend' => '',
'label' => 'Custom Tabs (JSON)',
'input' => 'textarea',
'required' => false,
'sort_order' => 200,
'global' => \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_STORE,
'visible' => true,
'user_defined' => true,
'used_in_product_listing' => false,
'visible_on_front' => true,
'wysiwyg_enabled' => false,
]
);
}
}
Admins can now open a product and paste structured JSON. Example JSON structure:
[
{"title":"Technical specs","content":"Weight: 1kg <br> Size: 10x10"},
{"title":"User manual","content":"<p>Download PDF: <a href=\"/pub/media/manual.pdf\">Manual</a></p>"},
{"title":"Warranty","content":"1 year manufacturer warranty"}
]
Pro tip: If JSON is too awkward for content editors, consider building a small admin UI (custom UI component) where editors add tabs via form rows. That becomes a premium feature idea we’ll discuss later.
Performance best practices
Tabs are part of the product page, which is often heavily cached in Magento. Follow these tips to ensure speed:
- Cacheability: Make your block cacheable. Implement getCacheKeyInfo and getCacheLifetime (we showed an example). Avoid using non-cacheable objects like session data inside the block.
- Full Page Cache (FPC): Ensure the block output does not contain per-session data. If tabs are product-specific, include product id + updated_at in cache key (shown earlier).
- Lazy-loading heavy content: If a tab contains heavy content (PDF embeds, external reviews), load that content via AJAX only when the tab is opened. This reduces initial page weight and improves Time To Interactive (TTI).
- Minimize queries: Don’t run custom collection queries per tab render. Read data attached to the product object or pre-load necessary relations.
- Use block template caching: If parts of the tab content are static or shared, consider using ESI/Edge Side Includes or hole-punching for dynamic fragments (for Adobe Commerce setups with Varnish).
- Avoid heavy JS frameworks: Use a tiny vanilla JS switcher (like in the example) rather than a large library. Let the theme handle styles to avoid CSS duplication.
Making it FPC-friendly and AJAX progressive enhancement
Example: Keep the default server-rendered tabs but for the heavy panel content, replace it with a lightweight placeholder and load the heavy content via AJAX on first activation. Illustration in template:
<!-- inside tabs.phtml -->
<section id="mf-tab-<?= $i ?>" data-ajax-url="<?= $block->getUrl('magefine_producttabs/ajax/content', ['id' => $product->getId(), 'tab' => $i]) ?>" class="mf-tab-panel" aria-hidden="true">
<div class="mf-tab-placeholder">Loading...</div>
</section>
<script>
// on tab open, fetch content once
// use fetch API to load the panel if data-ajax-url exists
</script>
This pattern ensures the initial HTML remains cacheable and light. The AJAX route can be a controller that returns rendered HTML for the specific tab and respects cache headers if content is cacheable.
Integration with existing product attributes and no-code customization
There are two main approaches to integrate with product attributes so non-devs can customize tabs:
- Single JSON attribute: As we implemented, a single text attribute holds an array of tabs. Pros: simple to implement. Cons: editing JSON can be error-prone for non-technical users.
- Multiple structured attributes or a custom admin UI: Create a custom admin UI (UI Component grid/form) so admins add/remove tabs row-by-row. Pros: user-friendly and ideal for premium extensions. Cons: more dev work.
If you prefer a no-code experience quickly, consider adding some UI helpers like a pre-filled JSON template or a small admin help block explaining JSON structure and examples.
Security and sanitization
Because admins may enter HTML, sanitize input at save time. You can:
- Strip dangerous tags via a whitelist on save using a backend model.
- Allow only safe URLs and escape attributes in templates.
- Use Magento's built-in
\Magento\Framework\Escaper
and WYSIWYG escapers if needed.
Concrete use-cases for product tabs
Here are real things you can put in tabs — helps when you want to sell the idea to clients:
- Technical sheets: Structured lists: weight, dimensions, power, materials.
- Customer reviews: Pull or cache third-party review snippets per product.
- User guides & manuals: Links to PDFs, step-by-step instructions.
- Installation guides: Useful for appliances or furniture.
- Compatibility tables: E.g., supported devices, compatible filters, etc.
- Warranty & shipping details: Short, scannable content that reduces pre-sales questions.
How to make the module more advanced (premium features)
If you plan to sell this module as a premium extension, here are features that justify a price tag:
- Admin UI for adding/removing ordered tabs with WYSIWYG editors (no JSON required).
- Per-store view tab visibility and translations (multi-store readiness).
- Tab visibility rules: show tab only for certain categories, attributes, or product types.
- Lazy-loading of heavy content with prefetch hints and skeleton UI.
- Import/export of tab templates and presets (useful for agencies rolling out multiple stores).
- Integration with review providers, video embeds, and structured data (schema.org) for each tab.
- Compatibility with page builders and popular themes.
When it comes to pricing and packaging, think of tiers: a free edition with JSON-attribute (basic), and a paid edition with admin UI, rule engine and priority support.
For inspiration on business models, pricing and packaging your Magento extension, see this resource explaining common models: Magefine business model. It covers one-time vs subscription licensing, support tiers, and distribution channels.
Packaging the extension for distribution
Make your module composer-installable so stores can add it easily:
{
"name": "magefine/product-tabs",
"description": "Custom product tabs module for Magento 2",
"type": "magento2-module",
"license": ["proprietary"],
"require": {
"php": ">=7.4",
"magento/framework": "~102.0|~103.0"
},
"autoload": {
"files": ["registration.php"],
"psr-4": {
"Magefine\\ProductTabs\\": ""
}
}
}
Also include clear install docs, a sample JSON, and instructions for enabling WYSIWYG or additional attributes. Provide a changelog and a tested Magento matrix (2.4.x etc.).
Testing and QA tips
- Test with and without JS to ensure progressive enhancement works.
- Test across themes—use a base Luma and a popular third-party theme.
- Test multi-store and translations if you add store view configs.
- Run performance tests (GTmetrix, Lighthouse) before/after adding module.
- Validate accessibility: keyboard navigation, aria attributes and focus handling.
How to sell this as a premium extension
Here are pragmatic steps to package and sell the extension:
- Offer a free lite version on Magento Marketplace or your site as an entry point.
- Premium features (admin UI, visibility rules, multi-store) behind a paid license.
- Use subscription licensing (annual) for predictable revenue; offer discounts for multi-year licenses.
- Provide docs, demo store, and migration/import guides so agencies can quickly test and adopt it.
- Offer white-label options and a developer/API license for agencies customizing the module.
- Bundle with installation or configuration services (additional revenue stream).
Again, for a breakdown of models and packaging tips, check this page: Magefine business model.
Real-world tips from development
- Keep the public API of your block small — expose getTabs() and getProduct(). Avoid tight coupling to internal helpers.
- Allow themes to override the template. Put clear instructions in your README for theme overrides (path: Magefine_ProductTabs::product/tabs.phtml).
- Add feature toggles via system config if you plan to roll out new behavior without releasing a new module version.
- Log useful errors during setup (like invalid JSON) but avoid spamming logs for normal catalog operations.
Advanced: structured data for tabs (SEO boost)
Some tabs (like reviews or FAQs) are great places to expose structured data. Consider outputting JSON-LD for facts like product manuals or FAQ items. That helps search engines and may improve SERP presentation.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Product",
"name": "<?= $block->escapeHtml($product->getName()) ?>",
// add relevant structured data for specific tabs (e.g. aggregateRating, faqPage)
}
</script>
Deployment checklist
- php bin/magento setup:upgrade
- php bin/magento setup:di:compile (production)
- php bin/magento setup:static-content:deploy <locales>
- Clear cache: php bin/magento cache:flush
- Test product pages on staging with FPC enabled
Summary: the core ideas to remember
- Keep the module simple and cache-friendly — product id + updated_at in cache key is a small but powerful trick.
- Use product attributes for easy admin customization; if you want a truly no-code UX, build a small admin UI as a premium feature.
- Make the tabs progressive (server-rendered by default), and lazy-load heavy pieces via AJAX to keep performance high.
- Package as composer-ready and document multi-store, theme override, and QA steps thoroughly.
Closing notes
If you follow the steps above you'll have a lightweight, maintainable and extendable product tabs module for Magento 2. Start simple: JSON attribute + server-rendered tabs, then iterate—add admin UI, visibility rules and AJAX heavy content loading as you learn what your customers need.
Need help packaging or selling this as a proper premium extension? Visit the Magefine business model resource for tips and licensing ideas: magefine.com/business-model. If you want, I can sketch the admin UI wireframe and the data model for a pro version next.
Happy coding — and ping me if you want the fully fleshed module files in a Git repo layout I can hand you.