Guide for building features, pages, tables, forms, themes, and navigation in this Next.js 16 shadcn dashboard template. Use this skill whenever the user wants to add a new page, create a feature module, build a data table, add a form, configure navigation items, add a theme, set up RBAC access control, or work with the dashboard's patterns and conventions. Also triggers when adding routes under /dashboard, working with Clerk auth/orgs/billing, creating mock APIs, or modifying the sidebar. Even if the user doesn't mention "dashboard" explicitly — if they're adding UI, pages, or features to this project, use this skill.
This skill encodes the exact patterns and conventions used in this Next.js 16 + shadcn/ui admin dashboard template.
| 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 |
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.
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.
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.
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>;
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.
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' />;
}
src/lib/searchparams.ts)Add any new filter keys. Existing params: page, perPage, name, gender, category, role, sort.
src/config/nav-config.ts){ title: 'Orders', url: '/dashboard/orders', icon: 'product', items: [] }
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 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.
The full pattern is shown in Steps 1-4 above. The key structure:
schemas/<name>.tsuseAppForm({ defaultValues, validators: { onSubmit: schema }, onSubmit }) + useFormFields<T>() for typed fieldsuseMutation({ ...createOrderMutation, onSuccess: () => { toast(); router.push() } }), spread shared mutation options from api/mutations.ts and layer on UI callbacksid === 'new' for create vs useSuspenseQuery(byIdOptions) for editFor 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.
The pattern is: server prefetch → HydrationBoundary → client useSuspenseQuery.
void queryClient.prefetchQuery(options) — fire-and-forget during SSR streaminguseSuspenseQuery(options) — picks up dehydrated data, suspends until resolvedWhy 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.
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.
See references/theming-guide.md for the complete guide. Quick steps:
src/styles/themes/<name>.css with OKLCH color tokens + @theme inline blocksrc/styles/theme.cssTHEMES array in src/components/themes/theme.config.tssrc/components/themes/font.config.tscn() for class merging — never concatenate className strings'use client' when neededvoid prefetchQuery() on server + useSuspenseQuery on clienttypes.ts → service.ts → queries.ts → mutations.ts per feature; queryOptions/mutationOptions as base abstractions (not custom hooks); getQueryClient() in mutations (not useQueryClient()); key factories (entityKeys.all/list/detail); components never import mock APIs directlysearchParamsCache on server, useQueryStates on client with shallow: true@/components/icons, never from @tabler/icons-react directlyuseAppForm + useFormFields<T>() from @/components/ui/tanstack-formPageContainer props, never import <Heading> manuallygetSortingStateParser from @/lib/parsers (same parser as useDataTable)