Skip to main content

webiny-admin-architect

Admin-side architecture patterns for Webiny extensions. Use this skill when building frontend features with headless features (UseCase/Repository/Gateway), presentation features (Presenter/ViewModel/hooks/components), MobX-based presenters, RegisterFeature, and Admin BuildParams. Covers the admin/ directory structure for both features/ and presentation/ layers.

Source facts

Repository
webiny/webiny-js
Last source activity
July 24, 2026 at 12:20
Detected SKILL.md language
English
Stars
8,049
Forks
682

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
webiny-admin-architect
description
Admin-side architecture patterns for Webiny extensions. Use this skill when building frontend features with headless features (UseCase/Repository/Gateway), presentation features (Presenter/ViewModel/hooks/components), MobX-based presenters, RegisterFeature, and Admin BuildParams. Covers the admin/ directory structure for both features/ and presentation/ layers.
# Admin Architecture Patterns ## TL;DR Admin extensions are React components that register headless features (business logic with no UI) and presentation features (MobX presenters, React hooks, components). Headless features live in `admin/features/` and follow **UseCase → Repository → Gateway** layering. Presentation features live in `admin/presentation/` and add a **Presenter** (MobX view model) layer on top. Both use `createFeature` and `createAbstraction` from `webiny/admin`. **All features — both headless and presentation — MUST provide a `resolve` function** in `createFeature`. This is how the `useFeature` hook accesses resolved instances from the DI container. Without `resolve`, the feature cannot be consumed from React. ## Working Context This skill applies to both **extension developers** (working in `extensions/`) and **core developers** (working in `packages/`). The architecture patterns are identical — only imports differ. | | Extensions (`extensions/`) | Core (`packages/`) | | ----------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Imports** | `webiny/admin`, `webiny/admin/ui`, `webiny/admin/cms/entry/editor` | `@webiny/app`, `@webiny/admin-ui`, `@webiny/app-headless-cms/...` | | **Catalog paths** | Use the `Import:` path | Use the `Source:` path | | **MobX wrapper** | `createReactiveComponent` from `webiny/admin` | `createReactiveComponent` from `@webiny/app-admin` (never import `observer` from `mobx-react-lite` directly — the only exception is `@webiny/app-admin` itself, which is the source of the re-export) | | **Entry point** | React component as `export default` in a file targeted by `<Admin.Extension src={...}>` | React component registered by the package initializer | Detect which context you're in by checking the file path: `extensions/` → extension mode, `packages/` → core mode. ## Admin Directory Structure ``` admin/ ├── Extension.tsx # Admin entry point (React component) ├── features/ # Headless features (business logic, no UI) │ └── EnableThing/ │ ├── abstractions.ts │ ├── EnableThingUseCase.ts │ ├── EnableThingRepository.ts │ ├── EnableThingGateway.ts │ └── feature.ts └── presentation/ # Presentation layer (hooks, components, presenters) └── CurrentThing/ ├── abstractions.ts # Presenter + ViewModel interfaces ├── CurrentThingPresenter.ts # MobX-based view model ├── useCurrentThing.ts # React hook for consumers ├── feature.ts # createFeature registration └── components/ # React UI components ``` ## Admin Extension Entry Point The Admin entry point is a React component that registers features, providers, UI decorators, and config: ```tsx // src/admin/Extension.tsx import React from "react"; import { AdminConfig, RegisterFeature } from "webiny/admin"; import { CurrentThingFeature } from "./presentation/CurrentThing/feature.js"; import { EnableThingFeature } from "./features/EnableThing/index.js"; import { CurrentThingProvider } from "./presentation/CurrentThing/CurrentThingProvider.js"; import { ThingListView } from "./presentation/ThingListView/ThingListView.js"; export const Extension = () => { return ( <> {/* Register headless features (use cases, repositories, gateways) */} <RegisterFeature feature={EnableThingFeature} /> {/* Register presentation features (presenters, view models) */} <RegisterFeature feature={CurrentThingFeature} /> {/* Providers and UI components */} <CurrentThingProvider /> <ThingListView /> {/* Admin config (menus, routes, etc.) */} <AdminConfig>{/* Menu items, route definitions, etc. */}</AdminConfig> </> ); }; ``` ## Headless Features (`features/`) Headless features contain business logic with no UI — use cases, repositories, and gateways. They follow the same layering as API features but use `webiny/admin` imports. ### Abstractions ```ts // src/admin/features/EnableThing/abstractions.ts import { createAbstraction } from "webiny/admin"; export interface IEnableThingUseCase { execute(id: string): Promise<void>; } export const EnableThingUseCase = createAbstraction<IEnableThingUseCase>("EnableThingUseCase"); export namespace EnableThingUseCase { export type Interface = IEnableThingUseCase; } export interface IEnableThingRepository { execute(id: string): Promise<void>; } export const EnableThingRepository = createAbstraction<IEnableThingRepository>("EnableThingRepository"); export namespace EnableThingRepository { export type Interface = IEnableThingRepository; } export interface IEnableThingGateway { enableThing(id: string): Promise<boolean>; } export const EnableThingGateway = createAbstraction<IEnableThingGateway>("EnableThingGateway"); export namespace EnableThingGateway { export type Interface = IEnableThingGateway; } ``` ### Feature Registration Headless features **must** provide a `resolve` function that returns the resolved use case (or multiple exports). This is what makes the feature consumable via `useFeature()` in the presentation layer: ```ts // src/admin/features/EnableThing/feature.ts import { createFeature } from "webiny/admin"; import { EnableThingUseCase as UseCase } from "./abstractions.js"; import { EnableThingUseCase } from "./EnableThingUseCase.js"; import { EnableThingRepository } from "./EnableThingRepository.js"; import { EnableThingGateway } from "./EnableThingGateway.js"; export const EnableThingFeature = createFeature({ name: "EnableThing", register(container) { container.register(EnableThingUseCase); container.register(EnableThingRepository).inSingletonScope(); container.register(EnableThingGateway).inSingletonScope(); }, resolve(container) { return { useCase: container.resolve(UseCase) }; } }); ``` ## The `useFeature` Hook — Bridging DI and React `useFeature` is the standard way to access headless features from React. It resolves the feature from the DI container and returns whatever `resolve` returned: ```tsx import { useFeature } from "webiny/admin"; import { EnableThingFeature } from "~/features/EnableThing/feature.js"; // In a presentation hook or component: const { useCase } = useFeature(EnableThingFeature); await useCase.execute(id); ``` Presentation hooks wrap `useFeature` to provide a clean, typed API to React components: ```tsx // src/admin/presentation/EnableThing/useEnableThing.ts import { useFeature } from "webiny/admin"; import { EnableThingFeature } from "~/features/EnableThing/feature.js"; export function useEnableThing() { const { useCase } = useFeature(EnableThingFeature); return { enableThing: useCase.execute.bind(useCase) }; } ``` ### Common `useFeature` patterns **Multiple features in one hook:** ```tsx export function useAuthentication() { const { useCase: logInUseCase } = useFeature(LogInFeature); const { useCase: logOutUseCase } = useFeature(LogOutFeature); return { login: logInUseCase.execute.bind(logInUseCase), logout: logOutUseCase.execute.bind(logOutUseCase) }; } ``` **MobX reactive state synced to React:** ```tsx export function useIdentity() { const { identityContext } = useFeature(IdentityContextFeature); const [identity, setIdentity] = useState(identityContext.getIdentity()); useEffect(() => { return autorun(() => { setIdentity(identityContext.getIdentity()); }); }, [identityContext]); return { identity, isAuthenticated: identity.isAuthenticated }; } ``` ## Presentation Features (`presentation/`) Presentation features contain the UI layer — a **Presenter** (MobX view model) that produces a ViewModel for React, plus hooks and components. The Presenter typically depends on a **headless feature abstraction** (use case or service) for data and actions — it does NOT duplicate the repository/gateway layering. ### Typical Pattern: Presenter depends on a headless feature The Presenter injects a headless feature's use case or service as a DI dependency: ```ts // src/admin/presentation/CurrentThing/abstractions.ts import { createAbstraction } from "webiny/admin"; import type { MyEntity } from "~/shared/MyEntity.js"; export interface ICurrentThingVm { loading: boolean; entity: MyEntity | undefined; error: Error | undefined; } export interface ICurrentThingPresenter { vm: ICurrentThingVm; init(): void; } export const CurrentThingPresenter = createAbstraction<ICurrentThingPresenter>("CurrentThingPresenter"); export namespace CurrentThingPresenter { export type Interface = ICurrentThingPresenter; export type ViewModel = ICurrentThingVm; } ``` ```ts // src/admin/presentation/CurrentThing/feature.ts import { createFeature } from "webiny/admin"; import { CurrentThingPresenter as PresenterAbstraction } from "./abstractions.js"; import { CurrentThingPresenter } from "./CurrentThingPresenter.js"; export const CurrentThingFeature = createFeature({ name: "CurrentThing", register(container) { container.register(CurrentThingPresenter); }, resolve(container) { return { presenter: container.resolve(PresenterAbstraction) }; } }); ``` The Presenter implementation injects the headless feature's abstraction (e.g., a use case): ```ts // src/admin/presentation/CurrentThing/CurrentThingPresenter.ts import { GetThingUseCase } from "~/features/GetThing/abstractions.js"; class CurrentThingPresenterImpl implements CurrentThingPresenter.Interface { vm: CurrentThingPresenter.ViewModel = { loading: false, entity: undefined, error: undefined }; constructor(private getThingUseCase: GetThingUseCase.Interface) {} async init() { this.vm.loading = true; const result = await this.getThingUseCase.execute(); // ... update vm } } export default CurrentThingPresenter.createImplementation({ implementation: CurrentThingPresenterImpl, dependencies: [GetThingUseCase] }); ``` ### One-off Pattern: Presenter with its own repository/gateway On rare occasions, when a presentation feature does not contain reusable business logic (no headless feature to depend on), the presentation feature can contain its own repository and gateway alongside the presenter: ```ts // src/admin/presentation/Dashboard/feature.ts — one-off, no headless feature import { createFeature } from "webiny/admin"; import { DashboardPresenter as PresenterAbstraction } from "./abstractions.js"; import { DashboardPresenter } from "./DashboardPresenter.js"; import { DashboardRepository } from "./DashboardRepository.js"; import { DashboardGateway } from "./DashboardGateway.js"; export const DashboardFeature = createFeature({ name: "Dashboard", register(container) { container.register(DashboardPresenter); container.register(DashboardRepository).inSingletonScope(); container.register(DashboardGateway).inSingletonScope(); }, resolve(container) { return { presenter: container.resolve(PresenterAbstraction) }; } }); ``` > **Prefer the typical pattern.** Only use the one-off pattern when the business logic is truly presentation-specific and will never be reused by other features. ## Observable Service Implementation For long-lived services that hold observable state (e.g., project config, feature flags): ```ts import { makeAutoObservable, runInAction } from "mobx"; import { WcpService as ServiceAbstraction, WcpGateway } from "./abstractions.js"; class WcpServiceImpl implements ServiceAbstraction.Interface { private project: ILicense | null = null; constructor(private gateway: WcpGateway.Interface) { makeAutoObservable(this); } getProject(): ILicense { return this.project; } async loadProject(): Promise<void> { const data = await this.gateway.fetchProject(); runInAction(() => { this.project = data; }); } } export const WcpService = ServiceAbstraction.createImplementation({ implementation: WcpServiceImpl, dependencies: [WcpGateway] }); ``` - Registered in **singleton scope** — long-lived, holds state - Use `makeAutoObservable(this)` in the constructor - Wrap async state mutations in `runInAction` ## Repository Implementation Repositories own domain data and cache. They use MobX for reactivity: ```ts import { makeAutoObservable, runInAction } from "mobx"; import { NextjsConfigRepository as RepositoryAbstraction, NextjsConfigGateway, NextjsConfig } from "./abstractions.js"; class NextjsConfigRepositoryImpl implements RepositoryAbstraction.Interface { private config: NextjsConfig | undefined = undefined; constructor(private gateway: NextjsConfigGateway.Interface) { makeAutoObservable(this); }
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub