Create Vendure Admin UI extensions with React components, route registration, navigation menus, and GraphQL integration. Handles useQuery, useMutation, useInjector patterns...
Guide creation of Vendure Admin UI extensions following React patterns and official conventions.
@vendure/admin-ui/react hooks for data fetchinguseInjector(NotificationService) for notificationsusePageMetadata() for titles and breadcrumbsregisterReactRouteComponent() for routesaddNavMenuSection() for navigation// ui/index.ts
import path from "path";
import { AdminUiExtension } from "@vendure/ui-devkit/compiler";
export const myPluginUiExtension: AdminUiExtension = {
id: "my-plugin-ui",
extensionPath: path.join(__dirname),
routes: [
{
route: "my-plugin",
filePath: "routes.ts",
},
],
providers: ["providers.ts"],
translations: {
en: path.join(__dirname, "translations/en.json"),
},
};
// ui/providers.ts
import { addNavMenuSection } from "@vendure/admin-ui/core";
export default [
addNavMenuSection(
{
id: "my-plugin",
label: "My Plugin",
items: [
{
id: "my-items",
label: "Items",
routerLink: ["/extensions/my-plugin/items"],
icon: "list",
},
{
id: "my-settings",
label: "Settings",
routerLink: ["/extensions/my-plugin/settings"],
icon: "cog",
},
],
},
"settings",
), // Position after 'settings' section
];
// ui/routes.ts
import { registerReactRouteComponent } from "@vendure/admin-ui/react";
import { ItemList } from "./components/ItemList";
import { ItemDetail } from "./components/ItemDetail";
import { ItemForm } from "./components/ItemForm";
export default [
registerReactRouteComponent({
component: ItemList,
path: "items",
title: "Items",
breadcrumb: "Items",
}),
registerReactRouteComponent({
component: ItemDetail,
path: "items/:id",
title: "Item Details",
breadcrumb: "Details",
}),
registerReactRouteComponent({
component: ItemForm,
path: "items/new",
title: "New Item",
breadcrumb: "New",
}),
];
// ui/components/ItemList.tsx
import { NotificationService } from '@vendure/admin-ui/core';
import {
Card,
ActionBar,
Link,
usePageMetadata,
PageBlock,
useQuery,
useMutation,
useInjector
} from '@vendure/admin-ui/react';
import * as React from 'react';
import { GET_ITEMS, DELETE_ITEM } from '../graphql';
import { GetItemsQuery, DeleteItemMutation } from '../gql/graphql';
export function ItemList() {
const { setTitle, setBreadcrumb } = usePageMetadata();
const notificationService = useInjector(NotificationService);
// Pagination state
const [currentPage, setCurrentPage] = React.useState(1);
const [itemsPerPage] = React.useState(25);
// Set page metadata
React.useEffect(() => {
setTitle('Items');
setBreadcrumb([
{ label: 'My Plugin', link: ['/extensions/my-plugin'] },
{ label: 'Items', link: ['/extensions/my-plugin/items'] }
]);
}, [setTitle, setBreadcrumb]);
// Query
const { data, loading, error, refetch } = useQuery<GetItemsQuery>(GET_ITEMS, {
variables: {
options: {
skip: (currentPage - 1) * itemsPerPage,
take: itemsPerPage,
}
}
});
// Delete mutation
const [deleteItem] = useMutation<DeleteItemMutation>(DELETE_ITEM);
const handleDelete = React.useCallback(async (id: string) => {
try {
await deleteItem({ input: { id } });
notificationService.success('Item deleted');
await refetch();
} catch {
notificationService.error('Failed to delete item');
}
}, [deleteItem, notificationService, refetch]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<PageBlock>
<ActionBar>
<div className="action-bar-start">
<h1>Items</h1>
</div>
<div className="action-bar-end">
<Link href="/extensions/my-plugin/items/new">
<button className="button primary">Add Item</button>
</Link>
</div>
</ActionBar>
<Card>
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{data?.items.items.map(item => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{new Date(item.createdAt).toLocaleDateString()}</td>
<td>
<Link href={`/extensions/my-plugin/items/${item.id}`}>
Edit
</Link>
<button onClick={() => void handleDelete(item.id)}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</Card>
</PageBlock>
);
}
// ui/components/ItemDetail.tsx
import { NotificationService } from '@vendure/admin-ui/core';
import {
usePageMetadata,
PageBlock,
useQuery,
useMutation,
useInjector,
useRouteParams
} from '@vendure/admin-ui/react';
import * as React from 'react';
import { GET_ITEM, UPDATE_ITEM } from '../graphql';
export function ItemDetail() {
const { setTitle, setBreadcrumb } = usePageMetadata();
const notificationService = useInjector(NotificationService);
const { id } = useRouteParams();
const [name, setName] = React.useState('');
// Query
const { data, loading, error } = useQuery(GET_ITEM, {
variables: { id }
});
// Mutation
const [updateItem, { loading: saving }] = useMutation(UPDATE_ITEM);
// Populate form when data loads
React.useEffect(() => {
if (data?.item) {
setName(data.item.name);
setTitle(data.item.name);
setBreadcrumb([
{ label: 'Items', link: ['/extensions/my-plugin/items'] },
{ label: data.item.name, link: [] }
]);
}
}, [data, setTitle, setBreadcrumb]);
const handleSubmit = React.useCallback(async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateItem({ input: { id, name } });
notificationService.success('Item updated');
} catch {
notificationService.error('Failed to update item');
}
}, [id, name, updateItem, notificationService]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<PageBlock>
<form onSubmit={(e) => void handleSubmit(e)}>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<button type="submit" disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</button>
</form>
</PageBlock>
);
}
// ui/graphql/queries.ts
import { gql } from "graphql-tag";
export const GET_ITEMS = gql`
query GetItems($options: ItemListOptions) {
items(options: $options) {
items {
id
name
createdAt
updatedAt
}
totalItems
}
}
`;
export const GET_ITEM = gql`
query GetItem($id: ID!) {
item(id: $id) {
id
name
createdAt
updatedAt
}
}
`;
// ui/graphql/mutations.ts
import { gql } from "graphql-tag";
export const CREATE_ITEM = gql`
mutation CreateItem($input: CreateItemInput!) {
createItem(input: $input) {
id
name
}
}
`;
export const UPDATE_ITEM = gql`
mutation UpdateItem($input: UpdateItemInput!) {
updateItem(input: $input) {
id
name
}
}
`;
export const DELETE_ITEM = gql`
mutation DeleteItem($input: DeleteItemInput!) {
deleteItem(input: $input)
}
`;
# ui/codegen.yml
schema: http://localhost:3000/admin-api
documents: "./ui/graphql/**/*.ts"
generates:
./ui/gql/graphql.ts:
plugins:
- typescript
- typescript-operations
config:
avoidOptionals: true
maybeValue: T | null
For detailed patterns with code examples, see references/PATTERNS.md.
For complete working examples, see references/EXAMPLES.md.
ui/
āāā index.ts # AdminUiExtension entry point
āāā providers.ts # Navigation registration
āāā routes.ts # Route registration
āāā codegen.yml # GraphQL codegen config
āāā components/
ā āāā ItemList.tsx
ā āāā ItemDetail.tsx
ā āāā ItemForm.tsx
ā āāā common/
ā āāā ConfirmDialog.tsx
ā āāā LoadingState.tsx
āāā graphql/
ā āāā index.ts # Re-exports
ā āāā queries.ts
ā āāā mutations.ts
ā āāā fragments.ts
āāā gql/
ā āāā graphql.ts # Generated types
āāā hooks/
ā āāā useItems.ts
āāā styles/
ā āāā common.css
āāā translations/
āāā en.json
| Problem | Cause | Solution |
|---|---|---|
| Component not rendering | Route not registered | Add to routes.ts with registerReactRouteComponent |
| Nav item missing | Provider not loaded | Add to providers.ts, ensure exported as default |
| Query returns undefined | Missing generated types | Run graphql-codegen |
| Notification not showing | Missing useInjector | Use useInjector(NotificationService) |
| Page title wrong | Missing usePageMetadata | Call setTitle in useEffect |