Admin grids give your team a familiar way to search, filter and act on custom data. Magento builds them from UI component XML rather than PHP block classes. In this tutorial we add an admin page that lists the FAQ entries from our declarative schema tutorial, with filters, sorting, paging and a mass delete action.

Step 1: Admin Menu and ACL

etc/adminhtml/menu.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/menu.xsd">
    <menu>
        <add id="MageServices_Faq::faq_manage"
             title="FAQs"
             module="MageServices_Faq"
             sortOrder="50"
             parent="Magento_Backend::content"
             action="mageservices_faq/faq/index"
             resource="MageServices_Faq::faq"/>
    </menu>
</config>

The MageServices_Faq::faq resource is declared in etc/acl.xml, as shown in our REST API tutorial.

Step 2: Admin Route and Controller

etc/adminhtml/routes.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="admin">
        <route id="mageservices_faq" frontName="mageservices_faq">
            <module name="MageServices_Faq"/>
        </route>
    </router>
</config>
Controller/Adminhtml/Faq/Index.php
<?php
declare(strict_types=1);

namespace MageServices\Faq\Controller\Adminhtml\Faq;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\Page;
use Magento\Framework\View\Result\PageFactory;

class Index extends Action implements HttpGetActionInterface
{
    public const ADMIN_RESOURCE = 'MageServices_Faq::faq';

    public function __construct(
        Context $context,
        private readonly PageFactory $pageFactory
    ) {
        parent::__construct($context);
    }

    public function execute(): Page
    {
        $page = $this->pageFactory->create();
        $page->setActiveMenu('MageServices_Faq::faq_manage');
        $page->getConfig()->getTitle()->prepend(__('FAQs'));

        return $page;
    }
}

Step 3: Layout

view/adminhtml/layout/mageservices_faq_faq_index.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>
        <referenceContainer name="content">
            <uiComponent name="mageservices_faq_listing"/>
        </referenceContainer>
    </body>
</page>

Step 4: Data Provider Wiring in di.xml

Instead of writing a data provider class, we register a collection for the grid's data source with a virtual type. Magento's generic SearchResult class handles filtering, sorting and paging.

etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\View\Element\UiComponent\DataProvider\CollectionFactory">
        <arguments>
            <argument name="collections" xsi:type="array">
                <item name="mageservices_faq_listing_data_source" xsi:type="string">MageServices\Faq\Model\ResourceModel\Faq\Grid\Collection</item>
            </argument>
        </arguments>
    </type>
    <virtualType name="MageServices\Faq\Model\ResourceModel\Faq\Grid\Collection"
                 type="Magento\Framework\View\Element\UiComponent\DataProvider\SearchResult">
        <arguments>
            <argument name="mainTable" xsi:type="string">mageservices_faq</argument>
            <argument name="resourceModel" xsi:type="string">MageServices\Faq\Model\ResourceModel\Faq</argument>
        </arguments>
    </virtualType>
</config>

Step 5: The Listing UI Component

view/adminhtml/ui_component/mageservices_faq_listing.xml
<?xml version="1.0" encoding="UTF-8"?>
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <argument name="data" xsi:type="array">
        <item name="js_config" xsi:type="array">
            <item name="provider" xsi:type="string">mageservices_faq_listing.mageservices_faq_listing_data_source</item>
        </item>
    </argument>
    <settings>
        <spinner>mageservices_faq_columns</spinner>
        <deps>
            <dep>mageservices_faq_listing.mageservices_faq_listing_data_source</dep>
        </deps>
    </settings>
    <dataSource name="mageservices_faq_listing_data_source" component="Magento_Ui/js/grid/provider">
        <settings>
            <storageConfig>
                <param name="indexField" xsi:type="string">entity_id</param>
            </storageConfig>
            <updateUrl path="mui/index/render"/>
        </settings>
        <aclResource>MageServices_Faq::faq</aclResource>
        <dataProvider class="Magento\Framework\View\Element\UiComponent\DataProvider\DataProvider"
                      name="mageservices_faq_listing_data_source">
            <settings>
                <requestFieldName>id</requestFieldName>
                <primaryFieldName>entity_id</primaryFieldName>
            </settings>
        </dataProvider>
    </dataSource>
    <listingToolbar name="listing_top">
        <bookmark name="bookmarks"/>
        <columnsControls name="columns_controls"/>
        <filterSearch name="fulltext"/>
        <filters name="listing_filters"/>
        <massaction name="listing_massaction">
            <action name="delete">
                <settings>
                    <confirm>
                        <message translate="true">Delete the selected FAQs?</message>
                        <title translate="true">Delete FAQs</title>
                    </confirm>
                    <url path="mageservices_faq/faq/massDelete"/>
                    <type>delete</type>
                    <label translate="true">Delete</label>
                </settings>
            </action>
        </massaction>
        <paging name="listing_paging"/>
    </listingToolbar>
    <columns name="mageservices_faq_columns">
        <selectionsColumn name="ids">
            <settings>
                <indexField>entity_id</indexField>
            </settings>
        </selectionsColumn>
        <column name="entity_id" sortOrder="10">
            <settings>
                <filter>textRange</filter>
                <label translate="true">ID</label>
                <sorting>desc</sorting>
            </settings>
        </column>
        <column name="question" sortOrder="20">
            <settings>
                <filter>text</filter>
                <label translate="true">Question</label>
            </settings>
        </column>
        <column name="is_active" component="Magento_Ui/js/grid/columns/select" sortOrder="30">
            <settings>
                <options class="Magento\Config\Model\Config\Source\Yesno"/>
                <filter>select</filter>
                <dataType>select</dataType>
                <label translate="true">Active</label>
            </settings>
        </column>
        <column name="sort_order" sortOrder="40">
            <settings>
                <filter>textRange</filter>
                <label translate="true">Sort Order</label>
            </settings>
        </column>
        <column name="updated_at" class="Magento\Ui\Component\Listing\Columns\Date" component="Magento_Ui/js/grid/columns/date" sortOrder="50">
            <settings>
                <filter>dateRange</filter>
                <dataType>date</dataType>
                <label translate="true">Updated</label>
            </settings>
        </column>
    </columns>
</listing>
The keyword search box (filterSearch) uses the fulltext index on question and answer we declared in db_schema.xml. Without a fulltext index, remove filterSearch from the toolbar.

Step 6: The Mass Delete Controller

Controller/Adminhtml/Faq/MassDelete.php
<?php
declare(strict_types=1);

namespace MageServices\Faq\Controller\Adminhtml\Faq;

use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\Result\Redirect;
use Magento\Ui\Component\MassAction\Filter;
use MageServices\Faq\Model\ResourceModel\Faq as FaqResource;
use MageServices\Faq\Model\ResourceModel\Faq\CollectionFactory;

class MassDelete extends Action implements HttpPostActionInterface
{
    public const ADMIN_RESOURCE = 'MageServices_Faq::faq';

    public function __construct(
        Context $context,
        private readonly Filter $filter,
        private readonly CollectionFactory $collectionFactory,
        private readonly FaqResource $faqResource
    ) {
        parent::__construct($context);
    }

    public function execute(): Redirect
    {
        $collection = $this->filter->getCollection($this->collectionFactory->create());
        $deleted = 0;

        foreach ($collection as $faq) {
            $this->faqResource->delete($faq);
            $deleted++;
        }

        $this->messageManager->addSuccessMessage(__('%1 FAQ(s) deleted.', $deleted));

        return $this->resultRedirectFactory->create()->setPath('*/*/');
    }
}

Step 7: Deploy and Test

bin/magento setup:upgrade
bin/magento cache:flush

Log in to the admin and open Content → FAQs. You can filter by ID, question, status and date, show or hide columns, save views with bookmarks and delete selected rows.

Common Errors

ErrorFix
Grid spinner never stopsThe data source name in the listing XML does not match the item name in di.xml.
"Class mageservices_faq_listing_data_source does not exist"The collections argument is missing or the virtual type name is wrong.
Empty gridCheck mainTable, primaryFieldName and that the table has rows.
403 or redirect to dashboardYour admin role does not have the ACL resource.

Adding an Edit Form

The natural next step is an edit form, built with a form UI component, a data provider class that loads one record, and Save and Delete controllers that use your repository. Keep validation in the repository or a service class so the admin form, REST API and imports all follow the same rules.

Frequently Asked Questions

Do I need a custom DataProvider class for an admin grid?

Not for simple grids. The generic DataProvider with a SearchResult virtual type handles filtering, sorting and paging. Write a custom provider when you need joins or computed data.

How do I add edit and delete links to each row?

Add an actionsColumn with a class that extends Magento\Ui\Component\Listing\Columns\Column and builds URLs in prepareDataSource().

Can I use a UI component grid on the storefront?

UI component listings are designed for the admin. On the storefront, especially with Hyvä, render data with templates and Alpine.js instead.

🚀

Need help with this on your store?

Our Magento engineers can implement it for you, review your code or take on the whole project.

Explore Magento Extension Development
MS
About the author

Written by the Magento Services engineering team — Magento 2, Adobe Commerce and Hyvä specialists since 2014. We write about problems we solve on real client stores.