How to Extend Pimcore Studio UI with a Custom Sidebar Button and a Widget with a Custom Grid Listing

Custom Studio UI extensions are useful when teams need project-specific tools, shortcuts, or data views directly inside the Pimcore admin interface. In this guide, we'll walk through how to extend the Pimcore Studio UI by adding a custom button to the left sidebar that opens a new widget panel.

We'll use the Pimcore Studio SDK to register a plugin, hook into the component slot system, and display fetched data inside a widget using the built-in Grid component. After that, we will add an option to add custom items to the Grid with a Form displayed in a Modal component. By the end of this guide, you'll know how to:

  • Set up a Pimcore Studio UI plugin
  • Register a component into the left sidebar slot
  • Register and open a custom widget
  • Use Pimcore Studio SDK components like Grid, Content, Spin, Alert, and Title inside your widget

Plugin Setup

The Pimcore Studio UI is built around a plugin system. Every custom extension starts with implementing the IAbstractPlugin interface and registering it with the Studio's module system.

The entry point of our plugin lives in index.ts:

index.ts
import { type IAbstractPlugin } from "@pimcore/studio-ui-bundle"; 

import { LeftSidebarExtension } from "./modules/sidebar-custom-listing-extension"; 

  

export const LeftSidebarPlugin: IAbstractPlugin = { 

    name: "LeftSidebarPlugin", 

  

    onStartup({ moduleSystem }) { 

        moduleSystem.registerModule(LeftSidebarExtension); 

    }, 

}; 

IAbstractPlugin is the base interface every Pimcore Studio plugin must implement. It requires at minimum a name and an onStartup lifecycle method.

onStartup receives a context object that contains the moduleSystem. This is the Studio's internal module registry, and calling moduleSystem.registerModule() is how you tell the Studio to include your module in the application bootstrap process. The module itself (LeftSidebarExtension) is where the real extension logic lives — more on that shortly.

The plugin is then exported from the bundle's main plugins.ts file:

plugins.ts
import { LeftSidebarPlugin } from "./sidebar-custom-listing"; 

export { LeftSidebarPlugin }; 

  

console.log("Pimcore Studio Bundle: LeftSidebarPlugin loaded"); 

This file acts as the public API of the bundle's JavaScript side. The Studio application will import plugins from here and register them during startup.

The Extension Module

The extension module is where you interact with the Studio's service container to register your components and widgets. This file is the heart of the integration.

sidebar-custom-listing-extension.ts
import { type AbstractModule, container } from "@pimcore/studio-ui-bundle"; 

import { serviceIds } from "@pimcore/studio-ui-bundle/app"; 

import { 

    componentConfig, 

    type ComponentRegistry, 

} from "@pimcore/studio-ui-bundle/modules/app"; 

import { type WidgetRegistry } from "@pimcore/studio-ui-bundle/modules/widget-manager"; 

import { CustomListingTriggerButton } from "../components/CustomListingTriggerButton"; 

import { WidgetWrapper } from "../components/WidgetWrapper"; 

  

export const LeftSidebarExtension: AbstractModule = { 

    onInit: (): void => { 

        const componentRegistry = container.get<ComponentRegistry>( 

            serviceIds["App/ComponentRegistry/ComponentRegistry"], 

        ); 

  

        const widgetRegistryService = container.get<WidgetRegistry>( 

            serviceIds.widgetManager, 

        ); 

  

        componentRegistry.registerToSlot( 

            componentConfig.leftSidebar.slot.name, 

            { 

                name: "customListingTriggerButton", 

                component: CustomListingTriggerButton, 

                priority: 201, 

            }, 

        ); 

  

        widgetRegistryService.registerWidget({ 

            name: "custom-listing-widget", 

            component: WidgetWrapper, 

        }); 

    }, 

}; 

Pimcore Studio-specific Parts Break Down

Let's break down the Pimcore Studio-specific parts:

AbstractModule is the interface your extension module must implement. It defines a single lifecycle method: onInit, which is called by the Studio during the module initialization phase.

container is the Studio's dependency injection container, based on InversifyJS. You use container.get<T>() to resolve services by their identifier. This is how you access internal Studio services without having to instantiate them yourself.

serviceIds is a map of all registered service identifiers in the Studio. Instead of using raw strings, always use serviceIds to reference services — this prevents typos and ensures compatibility when service names change between Studio versions.

  • serviceIds["App/ComponentRegistry/ComponentRegistry"] resolves the ComponentRegistry service
  • serviceIds.widgetManager resolves the WidgetRegistry service

ComponentRegistry and its registerToSlot method: the Studio UI is built around a slot system. Different areas of the interface (like the left sidebar, the toolbar, the context menu) expose named slots where third-party components can be injected. You retrieve the list of available slot names from componentConfig.

componentConfig.leftSidebar.slot. name gives you the correct slot name for the left sidebar without hardcoding a string. Using componentConfig for this is important — it gives you type safety and protects your code from breaking if slot names are ever refactored.

registerToSlot accepts:

  • The slot name (string)
  • An object with:
    • name: a unique identifier for this registration
    • component: the React component to render in that slot
    • priority: controls the render order within the slot (higher number = rendered earlier / higher up)

WidgetRegistry and registerWidget: the widget manager is the Studio's system for managing panel widgets — the tabbed content areas that open in the main workspace. Before you can open a widget programmatically, you must register it with the WidgetRegistry.

registerWidget takes:

  • name: a unique string identifier for the widget (you'll reference this when opening it)
  • component: the React component to render inside the widget panel

The Sidebar Button

Now that our button is registered in the left sidebar slot, let's look at the component itself.

CustomListingTriggerButton.tsx
import React from "react"; 

import { IconButton } from "@pimcore/studio-ui-bundle/components"; 

import { Tooltip } from "antd"; 

import { useWidgetManager } from "@pimcore/studio-ui-bundle/modules/widget-manager"; 

  

export const CustomListingTriggerButton: React.FC = () => { 

    const widgetManager = useWidgetManager(); 

  

    const handleClick = () => { 

        widgetManager.openMainWidget({ 

            name: "Custom Listing Grid", 

            id: "custom-listing-grid", 

            component: "custom-listing-widget", 

            config: { 

                icon: { 

                    type: "name", 

                    value: "list", 

                }, 

            }, 

        }); 

    }; 

  

    return ( 

        <Tooltip title="Show Custom Listing Grid" placement="right"> 

            <IconButton 

                icon={{ value: " list " }} 

                onClick={handleClick} 

                type={"text"} 

            /> 

        </Tooltip> 

    ); 

}; 

The key Pimcore Studio-specific pieces

useWidgetManager is a React hook exported from the Studio's widget-manager module. It gives your component access to the widget manager instance, so you can open, close, and manage widgets from within any React component. Using the hook keeps your component decoupled from the DI container.

widgetManager.openMainWidget() opens a widget in the main workspace area. It accepts a configuration object:

  • name: the display title shown in the widget tab
  • id: a unique instance identifier — if a widget with this id is already open, the Studio will focus it rather than opening a duplicate
  • component: the name string you used when calling widgetRegistryService.registerWidget() — this is how the widget manager knows which component to render
  • config.icon: defines the icon displayed on the widget tab; type: "name" means you're using a named icon from the Studio's icon set

IconButton is a button component from the Pimcore Studio UI component library (@pimcore/studio-ui-bundle/components). It renders a compact, icon-only button consistent with the Studio's design system. The icon prop accepts an object with a value that maps to a Studio icon name.

The Tooltip is from Ant Design (antd), which the Studio is built on top of. It wraps our button to display a tooltip on hover, using placement: "right" to account for the button's position on the left edge of the UI.

Here is how the button looks like in the admin:

Custom sidebar button added to the Pimcore Studio UI left navigation.

The Widget: Fetching Data and Displaying It

Once the button is clicked and the widget opens, the Studio renders our WidgetWrapper component. This component is responsible for fetching the recipe data and managing loading and error states before passing the data to the display layer.

First, let's look at the data layer:

recipesService.ts
const API_URL = "https://api.sampleapis.com/recipes/recipes"; 

const RECIPES_LIMIT = 50; 

  

export interface Recipe { 

    id: number; 

    title: string; 

    course: string; 

    mainIngredient: string; 

    totalTime: number; 

    calories: number; 

    photoUrl: string; 

} 

  

export const fetchRecipes = async (): Promise<Recipe[]> => { 

    const response = await fetch(API_URL); 

  

    if (!response.ok) { 

        throw new Error(`Failed to fetch recipes: ${response.statusText}`); 

    } 

  

    const data: Recipe[] = await response.json(); 

  

    return data.slice(0, RECIPES_LIMIT); 

}; 

This is straightforward data-fetching logic, but the Recipe interface is worth noting — it defines the shape of data that will be consumed by our grid component. Keeping this interface in the service layer makes it easy to share between the widget and any future components that might display recipe data.

Now the widget wrapper:

WidgetWrapper.tsx
import React, { useEffect, useState } from "react"; 

import { 

    Alert, 

    Content, 

    Spin, 

    Title, 

} from "@pimcore/studio-ui-bundle/components"; 

import { RecipesGrid } from "./RecipesGrid"; 

import { fetchRecipes, type Recipe } from "../services/recipesService"; 

  

const ERROR_MESSAGE = "Failed to load recipes. Please try again later."; 

  

export const WidgetWrapper = (): React.JSX.Element => { 

    const [recipes, setRecipes] = useState<Recipe[]>([]); 

    const [isLoading, setIsLoading] = useState<boolean>(true); 

    const [error, setError] = useState<string | null>(null); 

  

    useEffect(() => { 

        fetchRecipes() 

            .then((data) => { 

                setRecipes(data); 

            }) 

            .catch(() => { 

                setError(ERROR_MESSAGE); 

            }) 

            .finally(() => { 

                setIsLoading(false); 

            }); 

    }, []); 

  

    if (isLoading) { 

        return ( 

            <Content padded loading> 

                <Spin asContainer /> 

            </Content> 

        ); 

    } 

  

    if (error !== null) { 

        return ( 

            <Content padded> 

                <Alert type="error" message={error} /> 

            </Content> 

        ); 

    } 

  

    return ( 

        <Content padded> 

            <Title>Recipes</Title> 

            <RecipesGrid recipes={recipes} isLoading={isLoading} /> 

        </Content> 

    ); 

}; 

All UI components used here — Alert, Content, Spin, and Title — come from @pimcore/studio-ui-bundle/components. Using these instead of developing your own ensures your widget looks and feels native to the Studio UI.

Content is the standard layout wrapper for widget content in the Studio. The padded prop applies consistent internal spacing that matches the rest of the Studio's panels. The loading prop on Content puts the container into a loading state, which is used here in combination with the Spin component.

Spin with asContainer renders a centered loading spinner that fills its parent container. This is the Studio's recommended way to indicate that a widget panel is loading data.

Alert with type="error" renders a Studio-styled error banner. Using the Studio's Alert component rather than a plain div ensures your error state is visually consistent with how other Studio panels report errors.

Title renders a styled heading consistent with the Studio's typography system. Use it for widget section headings rather than plain HTML heading tags.

Finally, the grid:

RecipesGrid.tsx
import React from "react"; 

import { Content, Grid } from "@pimcore/studio-ui-bundle/components"; 

import { type ColumnDef } from "@tanstack/react-table"; 

import { type Recipe } from "../services/recipesService"; 

  

const THUMBNAIL_SIZE = 60; 

const CELL_VERTICAL_PADDING = 5; 

  

export interface RecipesGridProps { 

    recipes: Recipe[]; 

    isLoading: boolean; 

} 

  

const columns: Array<ColumnDef<Recipe>> = [ 

    { 

        accessorKey: "photoUrl", 

        header: "Photo", 

        enableSorting: false, 

        cell: ({ getValue }) => { 

            const url = getValue<string>(); 

  

            if (!url) { 

                return null; 

            } 

  

            return ( 

                <div 

                    style={{ 

                        paddingTop: CELL_VERTICAL_PADDING, 

                        paddingBottom: CELL_VERTICAL_PADDING, 

                        display: "flex", 

                        justifyContent: "center", 

                        alignItems: "center", 

                    }} 

                > 

                    <img 

                        src={url} 

                        alt="Recipe thumbnail" 

                        style={{ 

                            width: THUMBNAIL_SIZE, 

                            height: THUMBNAIL_SIZE, 

                            objectFit: "cover", 

                            borderRadius: 4, 

                        }} 

                    /> 

                </div> 

            ); 

        }, 

    }, 

    { 

        accessorKey: "title", 

        header: "Title", 

    }, 

    { 

        accessorKey: "course", 

        header: "Course", 

    }, 

    { 

        accessorKey: "mainIngredient", 

        header: "Main Ingredient", 

    }, 

    { 

        accessorKey: "totalTime", 

        header: "Total Time (min)", 

    }, 

    { 

        accessorKey: "calories", 

        header: "Calories", 

    }, 

]; 

  

export const RecipesGrid = ({ 

    recipes, 

    isLoading, 

}: RecipesGridProps): React.JSX.Element => { 

    return ( 

        <Content padded> 

            <Grid 

                data={recipes} 

                columns={columns} 

                isLoading={isLoading} 

                enableSorting={true} 

                autoWidth={true} 

            /> 

        </Content> 

    ); 

}; 

This is where the Pimcore Studio Grid component does the heavy lifting.

Grid is exported from @pimcore/studio-ui-bundle/components and is built on top of TanStack Table (react-table v8). This is why column definitions use the ColumnDef<T> type imported from @tanstack/react-table — the Studio's Grid component accepts standard TanStack Table column definitions, which gives you the full flexibility of TanStack Table's API while keeping the rendering consistent with the Studio's design system.

The key Grid props:

  • data: the array of records to display
  • columns: TanStack Table column definitions (Array<ColumnDef<T>>)
  • isLoading: when true, the Grid renders a loading skeleton in place of rows — this is separate from the widget-level loading state and useful for in-place refreshes
  • enableSorting: enables client-side column sorting; clicking a column header cycles through ascending, descending, and unsorted states
  • autoWidth: automatically distributes column widths based on available space

Column definitions follow the standard TanStack Table format. accessorKey maps to the property name on your data object. The custom cell renderer on the photoUrl column demonstrates how you can provide a render function to display anything you like in a cell — in this case, an image — while the other columns use the default renderer which simply displays the value as text. enableSorting: false on the photo column disables sorting for that specific column since sorting by image URL would not be meaningful.

This is now the full Widget with the Grid displaying our fetched recipes (the Add recipe button will be covered in the next section):

Custom Pimcore Studio widget displaying recipe data in a Grid listing.

Adding New Recipes with a Modal Form

With the recipe listing in place, the next step is to let users add new recipes from within the widget. We'll add a header row with the "Recipes" heading and an "Add recipe" button that opens a modal with a validated form. The photo field is a full drag-and-drop upload area styled to match the Studio's purple design system, converting the file to a data URL locally — no server round-trip needed. Newly added recipes appear at the top of the grid.

Three files change: we create two new components (AddRecipeModal and PhotoUploadField) and update WidgetWrapper.

First, the image upload field:

PhotoUploadField.tsx
import React from "react"; 

import { ConfigProvider, Upload } from "antd"; 

import { InboxOutlined } from "@ant-design/icons"; 

import type { RcFile } from "antd/es/upload/interface"; 

  

const { Dragger } = Upload; 

  

const DRAGGER_HEIGHT = 160; 

const PREVIEW_MAX_HEIGHT = 120; 

const PIMCORE_PRIMARY_COLOR = "#722ed1"; 

const PIMCORE_PRIMARY_BG_COLOR = "rgba(215, 199, 236, 0.4)"; 

const UPLOAD_THEME = { 

    token: { 

        colorPrimary: PIMCORE_PRIMARY_COLOR, 

        colorPrimaryBg: PIMCORE_PRIMARY_BG_COLOR, 

    }, 

}; 

  

export interface PhotoUploadFieldProps { 

    value?: string; 

    onChange?: (value: string) => void; 

} 

  

export const PhotoUploadField = ({ 

    value, 

    onChange, 

}: PhotoUploadFieldProps): React.JSX.Element => { 

    const handleBeforeUpload = (file: RcFile): false => { 

        const reader = new FileReader(); 

  

        reader.onload = (event) => { 

            const dataUrl = event.target?.result; 

  

            if (typeof dataUrl === "string") { 

                onChange?.(dataUrl); 

            } 

        }; 

  

        reader.readAsDataURL(file); 

  

        return false; 

    }; 

  

    return ( 

        <ConfigProvider theme={UPLOAD_THEME}> 

            <Dragger 

                accept="image/*" 

                beforeUpload={handleBeforeUpload} 

                showUploadList={false} 

                height={DRAGGER_HEIGHT} 

            > 

                {value !== undefined && value !== "" ? ( 

                    <> 

                        <img 

                            src={value} 

                            alt="Recipe photo preview" 

                            style={{ 

                                maxHeight: PREVIEW_MAX_HEIGHT, 

                                maxWidth: "100%", 

                                objectFit: "contain", 

                                display: "block", 

                                margin: "0 auto 8px", 

                            }} 

                        /> 

                        <p className="ant-upload-hint">Click or drag to change</p> 

                    </> 

                ) : ( 

                    <> 

                        <p className="ant-upload-drag-icon"> 

                            <InboxOutlined /> 

                        </p> 

                        <p className="ant-upload-text">Click or drag an image to upload</p> 

                    </> 

                )} 

            </Dragger> 

        </ConfigProvider> 

    ); 

}; 

The key design decision here is that PhotoUploadField acts as a controlled Form.Item child: it accepts value and onChange, the same contract that Form.Item uses to wire any custom component into its form state. Form.Item injects these props automatically — you do not call them yourself. onChange receives the raw value (a string here), not a DOM event, because Form.Item detects that the argument is not an event object and uses it directly.

Dragger is Ant Design's Upload.Draggera drag-and-drop capable variant of Upload. The Studio does not currently wrap this component, so we import it directly from antd, which is always available since the Studio bundles it.

The key props are:

  • accept="image/*" restricts the file picker to image files
  • beforeUpload returns false, which is the standard Ant Design pattern for intercepting and suppressing the default HTTP upload. Without this return value, antd would attempt to POST the file to the action URL.
  • showUploadList={false} hides the default file list that antd normally shows below the drop zone — we render our own preview instead.

FileReader.readAsDataURL() converts the binary file into a base64-encoded data URL. This happens asynchronously in the onload callback. Once the string is ready, we emit it via onChange, which causes Form.Item to store it in the form state and later include it in the values object returned by validateFields().

The two-branch render (preview vs. placeholder) uses value !== undefined && value !== "" to distinguish the "no photo chosen" state from the "photo chosen" state. When a photo is set, the Dragger still accepts new drops and clicks — the img element sits inside the Dragger's clickable surface, so the user can replace the image at any time without any extra controls.

The ant-upload-drag-icon and ant-upload-text class names are part of Ant Design's published Upload component API — they apply the standard icon sizing and typography from the Upload stylesheet. Using them keeps the upload area visually consistent with how other antd Upload components look in the Studio.

Ant Design's Upload.Dragger uses its own default blue (#1677ff) for borders, the drop icon, and hover highlights, which would look out of place inside the Studio's purple design system. We fix this by wrapping the Dragger in an antd ConfigProvider that overrides just two tokens:

  • colorPrimary (#722ed1) — the Studio's primary purple, applied to the dashed border, the icon color, and the hover/active border ring
  • colorPrimaryBg (rgba(215, 199, 236, 0.4)) — the Studio's light purple fill used for the drag-over background highlight

Both values are extracted from the Studio's design token definitions and stored as named constants so they stay in one place if the theme ever changes. The ConfigProvider scope is intentionally narrow — it wraps only the Dragger, so it does not affect any other antd components rendered elsewhere in the widget.

Now the modal that uses it:

AddRecipeModal.tsx
import React from "react"; 

import { 

    Button, 

    Form, 

    Input, 

    InputNumber, 

    Modal, 

    ModalFooter, 

} from "@pimcore/studio-ui-bundle/components"; 

import { PhotoUploadField } from "./PhotoUploadField"; 

import { type Recipe } from "../services/recipesService"; 

  

interface AddRecipeFormValues { 

    title: string; 

    course: string; 

    mainIngredient?: string; 

    totalTime?: number; 

    calories?: number; 

    photoUrl?: string; 

} 

  

export interface AddRecipeModalProps { 

    open: boolean; 

    onClose: () => void; 

    onAdd: (recipe: Recipe) => void; 

} 

  

const REQUIRED_FIELD_MESSAGE = "This field is required"; 

const REQUIRED_RULE = [{ required: true, message: REQUIRED_FIELD_MESSAGE }]; 

  

export const AddRecipeModal = ({ 

    open, 

    onClose, 

    onAdd, 

}: AddRecipeModalProps): React.JSX.Element => { 

    const [form] = Form.useForm<AddRecipeFormValues>(); 

  

    const handleSubmit = (): void => { 

        form.validateFields() 

            .then((values) => { 

                const newRecipe: Recipe = { 

                    id: Date.now(), 

                    title: values.title, 

                    course: values.course, 

                    mainIngredient: values.mainIngredient ?? "", 

                    totalTime: values.totalTime ?? 0, 

                    calories: values.calories ?? 0, 

                    photoUrl: values.photoUrl ?? "", 

                }; 

  

                onAdd(newRecipe); 

                form.resetFields(); 

                onClose(); 

            }) 

            .catch(() => { 

                // validateFields rejects when there are validation errors; these are shown inline by Form. 

            }); 

    }; 

  

    const handleCancel = (): void => { 

        form.resetFields(); 

        onClose(); 

    }; 

  

    return ( 

        <Modal 

            title="Add Recipe" 

            open={open} 

            onCancel={handleCancel} 

            footer={ 

                <ModalFooter justify="end"> 

                    <Button onClick={handleCancel}>Cancel</Button> 

                    <Button color="primary" onClick={handleSubmit}> 

                        Save 

                    </Button> 

                </ModalFooter> 

            } 

        > 

            <Form form={form} layout="vertical"> 

                <Form.Item name="title" label="Title" rules={REQUIRED_RULE}> 

                    <Input /> 

                </Form.Item> 

                <Form.Item name="course" label="Course" rules={REQUIRED_RULE}> 

                    <Input /> 

                </Form.Item> 

                <Form.Item name="mainIngredient" label="Main Ingredient"> 

                    <Input /> 

                </Form.Item> 

                <Form.Item name="totalTime" label="Total Time (min)"> 

                    <InputNumber min={0} style={{ width: "100%" }} /> 

                </Form.Item> 

                <Form.Item name="calories" label="Calories"> 

                    <InputNumber min={0} style={{ width: "100%" }} /> 

                </Form.Item> 

                <Form.Item name="photoUrl" label="Photo"> 

                    <PhotoUploadField /> 

                </Form.Item> 

            </Form> 

        </Modal> 

    ); 

}; 

Modal from @pimcore/studio-ui-bundle/components is a styled wrapper around Ant Design's Modal that integrates with the Studio's design system. The open prop (a boolean) controls visibility. onCancel is called when the user clicks the backdrop or the default close icon.

The footer prop accepts any ReactNode, which we use to render a ModalFooter. This replaces the default Ant Design footer with the Studio's opinionated footer layout. ModalFooter is a Flex-based container; justify="end" right-aligns the action buttons, which is the standard placement in Studio panels.

Form and Form.useForm come from @pimcore/studio-ui-bundle/components. Pimcore's Form is a thin wrapper around Ant Design's Form that adds a custom formInstanceType extending FormInstance with additional utilities (virtual validator support, triggerChange option). Typing the form instance via Form.useForm<AddRecipeFormValues>() gives type-safe access to field values when validateFields resolves.

Form.Item wires each field to the form instance via its name prop and passes value and onChange into the child component. For native inputs this is transparent; for custom components like PhotoUploadField it is the mechanism by which the data URL flows from the component back into the form state.

Input and InputNumber are Pimcore Studio wrappers around their Ant Design counterparts. InputNumber gets style={{ width: "100%" }} because numeric inputs don't stretch to fill their container by default.

Button from the Studio components accepts a color prop alongside Ant Design's type. color="primary" renders the Studio's primary action style without needing to coordinate type/color combinations.

The submit flow calls form.validateFields(), which resolves with typed values when all rules pass, or rejects when any field is invalid. On success we build the Recipe object using Date.now() as a local ID, call onAdd, then reset the form and close. The catch block prevents an unhandled promise rejection — the form already displays inline errors.

form.resetFields() in both handleSubmit and handleCancel ensures the form (including the photo preview) is always blank when reopened.

Finally, the updated WidgetWrapper:

WidgetWrapper.tsx (updated)
import React, { useCallback, useEffect, useState } from "react"; 

import { 

    Alert, 

    Button, 

    Content, 

    Spin, 

    Title, 

} from "@pimcore/studio-ui-bundle/components"; 

import { RecipesGrid } from "./RecipesGrid"; 

import { AddRecipeModal } from "./AddRecipeModal"; 

import { fetchRecipes, type Recipe } from "../services/recipesService"; 

  

const ERROR_MESSAGE = "Failed to load recipes. Please try again later."; 

const HEADER_STYLE: React.CSSProperties = { 

    display: "flex", 

    justifyContent: "space-between", 

    alignItems: "center", 

    padding: "8px 16px", 

}; 

const TITLE_STYLE: React.CSSProperties = { margin: 0 }; 

  

export const WidgetWrapper = (): React.JSX.Element => { 

    const [recipes, setRecipes] = useState<Recipe[]>([]); 

    const [isLoading, setIsLoading] = useState<boolean>(true); 

    const [error, setError] = useState<string | null>(null); 

    const [isModalOpen, setIsModalOpen] = useState<boolean>(false); 

  

    useEffect(() => { 

        fetchRecipes() 

            .then((data) => { setRecipes(data); }) 

            .catch(() => { setError(ERROR_MESSAGE); }) 

            .finally(() => { setIsLoading(false); }); 

    }, []); 

  

    const handleOpenModal = useCallback((): void => { 

        setIsModalOpen(true); 

    }, []); 

  

    const handleCloseModal = useCallback((): void => { 

        setIsModalOpen(false); 

    }, []); 

  

    const handleAddRecipe = useCallback((recipe: Recipe): void => { 

        setRecipes((prev) => [recipe, ...prev]); 

    }, []); 

  

    if (isLoading) { 

        return ( 

            <Content padded loading> 

                <Spin asContainer /> 

            </Content> 

        ); 

    } 

  

    if (error !== null) { 

        return ( 

            <Content padded> 

                <Alert type="error" message={error} /> 

            </Content> 

        ); 

    } 

  

    return ( 

        <> 

            <div style={HEADER_STYLE}> 

                <Title level={5} style={TITLE_STYLE}> 

                    Recipes 

                </Title> 

                <Button color="primary" onClick={handleOpenModal}> 

                    Add recipe 

                </Button> 

            </div> 

            <Content padded> 

                <RecipesGrid recipes={recipes} isLoading={isLoading} /> 

            </Content> 

            <AddRecipeModal 

                open={isModalOpen} 

                onClose={handleCloseModal} 

                onAdd={handleAddRecipe} 

            /> 

        </> 

    ); 

}; 

The header row is a plain div with inline flex styles. The two style objects (HEADER_STYLE and TITLE_STYLE) are defined as module-level constants so they are not recreated on every render. HEADER_STYLE is typed as React.CSSProperties for TypeScript completeness.

Title from the Studio components extends Ant Design's Typography.Title, adding theme, weight, and icon props. The level prop (1–5) maps to the corresponding HTML heading level; level={5} produces a compact heading appropriate for a panel header row. The style={{ margin: 0 }} override in TITLE_STYLE is necessary because AntD's typography headings carry default vertical margins that would otherwise push the row taller than intended.

handleAddRecipe uses [recipe, ...prev] (prepend) instead of [...prev, recipe] (append), so newly created recipes appear at the top of the grid immediately, giving the user direct visual confirmation that the save worked.

We added an isModalOpen boolean to state. This is the single source of truth for the modal's visibility — passed as open to AddRecipeModal. The handlers are wrapped in useCallback to avoid unnecessary re-renders of child components that receive them as props.

The AddRecipeModal is rendered as a sibling of the header div and Content (inside the Fragment), not nested inside Content. This prevents layout clipping — modals rendered inside overflow:hidden containers can be cut off — and is consistent with how Studio modals are expected to be placed so they can portal correctly to the document body.

This is how the modal looks like:

Add Recipe modal form opened inside a custom Pimcore Studio widget.

Validation failed state:

Validation error state in an Add Recipe modal form inside Pimcore Studio UI.

Summary

Here's a recap of the Pimcore Studio SDK touchpoints we used in this guide:

  • IAbstractPlugin: Base interface for all Studio plugins; entry point for registering modules
  • AbstractModule: Interface for extension modules; onInit is called during Studio bootstrap
  • container: InversifyJS DI container; use container.get<T>() to resolve Studio services
  • serviceIds: Map of all Studio service identifiers; always use this instead of raw strings
  • componentConfig: Configuration map for Studio UI slots and component areas
  • ComponentRegistry: Service for injecting components into Studio UI slots
  • WidgetRegistry: Service for registering widget components by name
  • useWidgetManager: React hook for interacting with the widget manager in components
  • widgetManager.openMainWidget(): Opens a registered widget in the main workspace
  • Content: Layout wrapper with consistent Studio padding and loading states
  • Grid: TanStack Table-backed data grid with Studio styling
  • Spin: Loading spinner; asContainer variant fills the parent area
  • Alert: Styled notification/error banner
  • Title: Typography heading; level prop maps to h1–h5; extends AntD Typography.Title
  • IconButton: Compact icon-only button consistent with Studio design
  • Button: Studio-styled button; color="primary" for primary actions
  • ConfigProvider (antd): Scoped antd theme override; used to apply Studio purple tokens to Upload.Dragger
  • Modal: Studio-styled modal dialog; open prop controls visibility
  • ModalFooter: Flex container for modal action buttons
  • Form: Ant Design Form wrapper with Pimcore's custom formInstanceType
  • Form.useForm: Hook to create a typed form instance
  • Form.Item: Field wrapper; injects value/onChange into child; supports validation rules
  • Input: Studio-styled single-line text input
  • InputNumber: Studio-styled numeric input
  • Upload.Dragger (antd): Drag-and-drop file zone; beforeUpload returning false suppresses HTTP upload

Extending the Pimcore Studio UI can feel overwhelming at first, especially when working with custom hooks, dependency injection, component registration, and Studio-specific services. However, once the basic structure is clear, the extension model becomes much easier to follow with the help of the official Pimcore documentation, Storybook, and TypeScript.

The React-based Studio approach gives developers a more modern foundation for building custom admin functionality. Instead of relying on fragile ExtJS overrides, teams can use typed interfaces, reusable components, hooks, and registered extension points to create UI additions that are easier to maintain and visually consistent with the rest of Pimcore Studio.

For businesses using Pimcore in complex environments, this matters because custom admin extensions can bring project-specific tools, shortcuts, and data views closer to the people who work with product information every day.

Looking for Exponential Growth? Let’s Get Started.
Explore next

Pimcore Whitepapers

Our Pimcore knowledge in your hands. Read and download our free whitepapers.

Discover more