kiranism-shadcn-dashboard
Dashboard Development Guide
This skill encodes the exact patterns and conventions used in this Next.js 16 + shadcn/ui admin dashboard template.
Quick Reference: What Goes Where
| Task | Location |
|---|---|
| New page | src/app/dashboard/<name>/page.tsx |
| New feature module | src/features/<name>/ |
| Feature components | src/features/<name>/components/ |
| API types | src/features/<name>/api/types.ts |
| Service layer | src/features/<name>/api/service.ts |
| Query options | src/features/<name>/api/queries.ts |
| Mutation options | src/features/<name>/api/mutations.ts |
| Zod schemas | src/features/<name>/schemas/<name>.ts |
| Filter/select options | src/features/<name>/constants/ |
| Nav config | src/config/nav-config.ts |
| Types | src/types/index.ts |
| Mock data | src/constants/mock-api-<name>.ts |
| Search params | src/lib/searchparams.ts |
| Query client | src/lib/query-client.ts |
| Theme CSS | src/styles/themes/<name>.css |
| Theme registry | src/components/themes/theme.config.ts |
| Custom hook | src/hooks/ |
| Icons registry | src/components/icons.tsx |
Adding a New Feature (End-to-End)
When a user asks to add a feature (e.g., "add an orders page"), follow these steps in order. Each step below shows the minimal pattern — see reference files for full templates.
Step 1: Mock API (src/constants/mock-api-<name>.ts)
See references/mock-api-guide.md for the complete template. Key structure:
import { faker } from '@faker-js/faker';
import { matchSorter } from 'match-sorter';
import { delay } from './mock-api';
export type Order = {
id: number;
customer: string;
status: string;
total: number;
created_at: string;
updated_at: string;
};
export const fakeOrders = {
records: [] as Order[],
initialize() {
/* generate with faker */
},
async getOrders({ page, limit, search, sort }) {
/* filter, sort, paginate, return { items, total_items } */
},
async getOrderById(id: number) {
/* find by id */
},
async createOrder(data) {
/* push to records */
},
async updateOrder(id, data) {
/* merge into record */
},
async deleteOrder(id) {
/* filter out */
}
};
fakeOrders.initialize();
Every method should call await delay(800) to simulate network latency. Use matchSorter for search. Return { items, total_items } from list methods.
Step 2: API Layer (src/features/<name>/api/)
Each feature has 4 API files: types → service → queries → mutations.
Types (api/types.ts) — re-export the entity type from mock API, plus filter/response/payload types:
export type { Order } from '@/constants/mock-api-orders';
export type OrderFilters = { page?: number; limit?: number; search?: string; sort?: string };
export type OrdersResponse = { items: Order[]; total_items: number };
export type OrderMutationPayload = { customer: string; status: string; total: number };
Service (api/service.ts) — data access layer. One exported function per operation:
import { fakeOrders } from '@/constants/mock-api-orders';
import type { OrderFilters, OrdersResponse, OrderMutationPayload } from './types';
export async function getOrders(filters: OrderFilters): Promise<OrdersResponse> {
return fakeOrders.getOrders(filters);
}
export async function getOrderById(id: number) {
return fakeOrders.getOrderById(id);
}
export async function createOrder(data: OrderMutationPayload) {
return fakeOrders.createOrder(data);
}
export async function updateOrder(id: number, data: OrderMutationPayload) {
return fakeOrders.updateOrder(id, data);
}
export async function deleteOrder(id: number) {
return fakeOrders.deleteOrder(id);
}
Queries (api/queries.ts) — query key factory + query options:
import { queryOptions } from '@tanstack/react-query';
import { getOrders, getOrderById } from './service';
import type { Order, OrderFilters } from './types';
export type { Order };
export const orderKeys = {
all: ['orders'] as const,
list: (filters: OrderFilters) => [...orderKeys.all, 'list', filters] as const,
detail: (id: number) => [...orderKeys.all, 'detail', id] as const
};
export const ordersQueryOptions = (filters: OrderFilters) =>
queryOptions({
queryKey: orderKeys.list(filters),
queryFn: () => getOrders(filters)
});
export const orderByIdOptions = (id: number) =>
queryOptions({
queryKey: orderKeys.detail(id),
queryFn: () => getOrderById(id)
});
Mutations (api/mutations.ts) — use mutationOptions + getQueryClient() (not custom hooks with useQueryClient()):
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { createOrder, updateOrder, deleteOrder } from './service';
import { orderKeys } from './queries';
import type { OrderMutationPayload } from './types';
export const createOrderMutation = mutationOptions({
mutationFn: (data: OrderMutationPayload) => createOrder(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
}
});
export const updateOrderMutation = mutationOptions({
mutationFn: ({ id, values }: { id: number; values: OrderMutationPayload }) =>
updateOrder(id, values),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
}
});
export const deleteOrderMutation = mutationOptions({
mutationFn: (id: number) => deleteOrder(id),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
}
});
mutationOptions is the right abstraction because it works outside React (event handlers, tests, utilities), composes via spread at the call site, and uses getQueryClient() which handles both SSR (fresh per request) and client (singleton) correctly. See references/query-abstractions.md for the full rationale.
Step 3: Zod Schema (src/features/<name>/schemas/<name>.ts)
import { z } from 'zod';
export const orderSchema = z.object({
customer: z.string().min(2, 'Customer name must be at least 2 characters'),
status: z.string().min(1, 'Please select a status'),
total: z.number({ message: 'Total is required' })
});
export type OrderFormValues = z.infer<typeof orderSchema>;
Step 4: Feature Components
Create src/features/<name>/components/ with:
Listing page (server component — <name>-listing.tsx):
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import { ordersQueryOptions } from '../api/queries';
import { OrderTable, OrderTableSkeleton } from './orders-table';
import { Suspense } from 'react';
export default function OrderListingPage() {
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(ordersQueryOptions(filters));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<OrderTableSkeleton />}>
<OrderTable />
</Suspense>
</HydrationBoundary>
);
}
Table + skeleton (client component — orders-table/index.tsx):
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { getSortingStateParser } from '@/lib/parsers';
import { useDataTable } from '@/hooks/use-data-table';
import { DataTable } from '@/components/ui/table/data-table';
import { DataTableToolbar } from '@/components/ui/table/data-table-toolbar';
import { Skeleton } from '@/components/ui/skeleton';
import { ordersQueryOptions } from '../../api/queries';
import { columns } from './columns';
const columnIds = columns.map((c) => c.id).filter(Boolean) as string[];
export function OrderTable() {
const [params] = useQueryStates({
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
name: parseAsString,
sort: getSortingStateParser(columnIds).withDefault([])
});
const filters = {
page: params.page,
limit: params.perPage,
...(params.name && { search: params.name }),
...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) })
};
const { data } = useSuspenseQuery(ordersQueryOptions(filters));
const { table } = useDataTable({
data: data.items,
columns,
pageCount: Math.ceil(data.total_items / params.perPage),
shallow: true,
debounceMs: 500,
initialState: { columnPinning: { right: ['actions'] } }
});
return (
<DataTable table={table}>
<DataTableToolbar table={table} />
</DataTable>
);
}
export function OrderTableSkeleton() {
return (
<div className='space-y-4 p-4'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-96 w-full' />
</div>
);
}
Column definitions (orders-table/columns.tsx):
Each column needs id, accessorKey (or accessorFn), header with DataTableColumnHeader, and optionally meta for filtering + enableColumnFilter: true.
export const columns: ColumnDef<Order>[] = [
{
id: 'customer',
accessorKey: 'customer',
header: ({ column }) => <DataTableColumnHeader column={column} title='Customer' />,
meta: { label: 'Customer', placeholder: 'Search...', variant: 'text', icon: Icons.text },
enableColumnFilter: true
},
{
id: 'status',
accessorKey: 'status',
header: ({ column }) => <DataTableColumnHeader column={column} title='Status' />,
cell: ({ cell }) => (
<Badge variant='outline' className='capitalize'>
{cell.getValue<string>()}
</Badge>
),
enableColumnFilter: true,
meta: { label: 'Status', variant: 'multiSelect', options: STATUS_OPTIONS }
},
{ id: 'actions', cell: ({ row }) => <CellAction data={row.original} /> }
];
Filter meta.variant options: text, number, range, date, dateRange, select, multiSelect, boolean. For multiSelect, provide options: { value, label, icon? }[].
Cell actions (orders-table/cell-action.tsx):
Pattern: DropdownMenu with edit/delete items + AlertModal for delete confirmation + useMutation for the delete API call.
import { deleteOrderMutation } from '../../api/mutations';
export const CellAction: React.FC<{ data: Order }> = ({ data }) => {
const [deleteOpen, setDeleteOpen] = useState(false);
const deleteMutation = useMutation({
...deleteOrderMutation,
onSuccess: () => {
toast.success('Deleted');
setDeleteOpen(false);
}
});
return (
<>
<AlertModal
isOpen={deleteOpen}
onClose={() => setDeleteOpen(false)}
onConfirm={() => deleteMutation.mutate(data.id)}
loading={deleteMutation.isPending}
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' className='h-8 w-8 p-0'>
<Icons.ellipsis className='h-4 w-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem onClick={() => router.push(`/dashboard/orders/${data.id}`)}>
<Icons.edit className='mr-2 h-4 w-4' /> Edit
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleteOpen(true)}>
<Icons.trash className='mr-2 h-4 w-4' /> Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};
For sheet-based editing (like Users), replace router.push with opening a <FormSheet> — see the Forms section below.
Step 5: Page Route (src/app/dashboard/<name>/page.tsx)
import PageContainer from '@/components/layout/page-container';
import OrderListingPage from '@/features/orders/components/order-listing';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export const metadata = { title: 'Dashboard: Orders' };
type PageProps = { searchParams: Promise<SearchParams> };
export default async function Page(props: PageProps) {
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
return (
<PageContainer
scrollable={false}
pageTitle='Orders'
pageDescription='Manage your orders.'
pageHeaderAction={/* Add button — Link or SheetTrigger */}
>
<OrderListingPage />
</PageContainer>
);
}
PageContainer props: scrollable, pageTitle, pageDescription, pageHeaderAction (React node for the top-right button), infoContent (help sidebar), access + accessFallback (RBAC gating).
Detail/Edit page (src/app/dashboard/<name>/[id]/page.tsx):
import PageContainer from '@/components/layout/page-container';
import OrderViewPage from '@/features/orders/components/order-view-page';
export const metadata = { title: 'Dashboard: Order Details' };
type PageProps = { params: Promise<{ id: string }> };
export default async function Page(props: PageProps) {
const { id } = await props.params;
return (
<PageContainer scrollable pageTitle='Order Details'>
<OrderViewPage orderId={id} />
</PageContainer>
);
}
View page component (client — handles new vs edit):
'use client';
import { useSuspenseQuery } from '@tanstack/react-query';
import { notFound } from 'next/navigation';
import { orderByIdOptions } from '../api/queries';
import OrderForm from './order-form';
export default function OrderViewPage({ orderId }: { orderId: string }) {
if (orderId === 'new') return <OrderForm initialData={null} pageTitle='Create Order' />;
const { data } = useSuspenseQuery(orderByIdOptions(Number(orderId)));
if (!data) notFound();
return <OrderForm initialData={data} pageTitle='Edit Order' />;
}
Step 6: Search Params (src/lib/searchparams.ts)
Add any new filter keys. Existing params: page, perPage, name, gender, category, role, sort.
Step 7: Navigation (src/config/nav-config.ts)
{ title: 'Orders', url: '/dashboard/orders', icon: 'product', items: [] }
Step 8: Icons (src/components/icons.tsx)
To register a new icon, import from @tabler/icons-react and add to the Icons object:
import { IconShoppingCart } from '@tabler/icons-react';
export const Icons = { /* ...existing */ cart: IconShoppingCart };
Never import @tabler/icons-react anywhere else. Always use Icons.keyName.
Existing icon keys (partial): dashboard, product, kanban, chat, forms, user, teams, billing, settings, add, edit, trash, search, check, close, clock, ellipsis, text, calendar, upload, spinner, chevronDown/Left/Right/Up, sun, moon, palette, pro, workspace, notification.
Forms
Forms use TanStack Form + Zod with useAppForm + useFormFields<T>() and useMutation for submission. See references/forms-guide.md for all field types, validation strategies, multi-step forms, and advanced patterns.
Page Form (Create/Edit on a dedicated route)
The full pattern is shown in Steps 1-4 above. The key structure:
- Schema — Zod schema + inferred type in
schemas/<name>.ts - Form component —
useAppForm({ defaultValues, validators: { onSubmit: schema }, onSubmit })+useFormFields<T>()for typed fields - Mutations —
useMutation({ ...createOrderMutation, onSuccess: () => { toast(); router.push() } }), spread shared mutation options fromapi/mutations.tsand layer on UI callbacks - View page — client component that checks
id === 'new'for create vsuseSuspenseQuery(byIdOptions)for edit
Sheet Form (Inline create/edit in a side panel)
For features where a separate page is overkill (like Users). The sheet manages open state; the form uses a form attribute to connect to the sheet footer's submit button.
'use client';
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
export function OrderFormSheet({
order,
open,
onOpenChange
}: {
order?: Order;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const isEdit = !!order;
const mutation = useMutation({
...(isEdit ? updateOrderMutation : createOrderMutation),
onSuccess: () => {
onOpenChange(false);
}
});
const form = useAppForm({
defaultValues: { customer: order?.customer ?? '' /* ... */ } as OrderFormValues,
validators: { onSubmit: orderSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync(value);
}
});
const { FormTextField, FormSelectField } = useFormFields<OrderFormValues>();
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className='flex flex-col'>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit' : 'New'} Order</SheetTitle>
</SheetHeader>
<div className='flex-1 overflow-auto'>
<form.AppForm>
<form.Form id='order-sheet-form' className='space-y-4'>
<FormTextField name='customer' label='Customer' required />
<FormSelectField name='status' label='Status' required options={STATUS_OPTIONS} />
</form.Form>
</form.AppForm>
</div>
<SheetFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type='submit' form='order-sheet-form' disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : isEdit ? 'Update' : 'Create'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
For cell actions, add const [editOpen, setEditOpen] = useState(false) and render <OrderFormSheet order={data} open={editOpen} onOpenChange={setEditOpen} /> with a <DropdownMenuItem onClick={() => setEditOpen(true)}>. For the page header "Add" button, create a trigger component that manages open state and renders the sheet.
Available field components from useFormFields<T>(): FormTextField, FormTextareaField, FormSelectField, FormCheckboxField, FormSwitchField, FormRadioGroupField, FormSliderField, FormFileUploadField.
Data Fetching with React Query
The pattern is: server prefetch → HydrationBoundary → client useSuspenseQuery.
- Server:
void queryClient.prefetchQuery(options)— fire-and-forget during SSR streaming - Client:
useSuspenseQuery(options)— picks up dehydrated data, suspends until resolved - HydrationBoundary + dehydrate: bridges server cache → client cache
- Suspense fallback: skeleton shown while data streams
Why useSuspenseQuery not useQuery: useQuery doesn't integrate with Suspense — it shows loading even when data is prefetched. useSuspenseQuery picks up the dehydrated pending query. Once cached (within staleTime: 60s), subsequent visits are instant.
Mutations use mutationOptions + getQueryClient() in mutations.ts, composed via spread at the call site:
// In mutations.ts — shared config
export const createOrderMutation = mutationOptions({
mutationFn: (data) => createOrder(data),
onSuccess: () => {
getQueryClient().invalidateQueries({ queryKey: orderKeys.all });
}
});
// In component — spread + layer UI callbacks
const mutation = useMutation({
...createOrderMutation,
onSuccess: () => toast.success('Created')
});
See references/query-abstractions.md for why mutationOptions/queryOptions are the right abstraction over custom hooks.
Navigation & RBAC
Configure in src/config/nav-config.ts. Items are filtered client-side in src/hooks/use-nav.ts using Clerk.
Access control properties on nav items:
requireOrg: boolean— requires active Clerk organizationpermission: string— requires specific Clerk permissionrole: string— requires specific Clerk roleplan: string— requires subscription plan (server-side)feature: string— requires feature flag (server-side)
Items without access are visible to everyone. All client-side checks are synchronous — no loading states.
Themes
See references/theming-guide.md for the complete guide. Quick steps:
- Create
src/styles/themes/<name>.csswith OKLCH color tokens +@theme inlineblock - Import in
src/styles/theme.css - Register in
THEMESarray insrc/components/themes/theme.config.ts - (Optional) Add Google Fonts in
src/components/themes/font.config.ts
Code Conventions
cn()for class merging — never concatenate className strings- Server components by default — only add
'use client'when needed - React Query —
void prefetchQuery()on server +useSuspenseQueryon client - API layer —
types.ts→service.ts→queries.ts→mutations.tsper feature;queryOptions/mutationOptionsas base abstractions (not custom hooks);getQueryClient()in mutations (notuseQueryClient()); key factories (entityKeys.all/list/detail); components never import mock APIs directly - nuqs —
searchParamsCacheon server,useQueryStateson client withshallow: true - Icons — only from
@/components/icons, never from@tabler/icons-reactdirectly - Forms —
useAppForm+useFormFields<T>()from@/components/ui/tanstack-form - Page headers —
PageContainerprops, never import<Heading>manually - Sort parser — use
getSortingStateParserfrom@/lib/parsers(same parser asuseDataTable) - Formatting — single quotes, JSX single quotes, no trailing comma, 2-space indent
More from kiranism/next-shadcn-dashboard-starter
tanstack-query
TanStack Query v5 data fetching patterns including useSuspenseQuery, useQuery, mutations, cache management, and API service integration. Use when fetching data, managing server state, or working with TanStack Query hooks.
14tanstack-form
Headless, performant, and type-safe form state management for TS/JS, React, Vue, Angular, Solid, Lit, and Svelte.
9next-best-practices
Next.js best practices - file conventions, RSC boundaries, data patterns, async APIs, metadata, error handling, route handlers, image/font optimization, bundling
6shadcn
Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset".
5frontend-design
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
5web-design-guidelines
Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
5