Agent Skills › Kiranism/next-shadcn-dashboard-starter › kiranism-shadcn-dashboard

kiranism-shadcn-dashboard

GitHub

Next.js shadcn仪表盘开发指南,规范页面、功能模块、表单及导航的构建模式。涵盖Mock API、TanStack Query分层、主题配置与RBAC集成,确保项目结构一致性与可维护性。

.agents/skills/kiranism-shadcn-dashboard/SKILL.md Kiranism/next-shadcn-dashboard-starter

触发场景

添加新页面或功能模块 构建数据表格或表单 配置导航或主题 设置RBAC访问控制

安装

npx skills add Kiranism/next-shadcn-dashboard-starter --skill kiranism-shadcn-dashboard -g -y
更多选项

非标准路径

npx skills add https://github.com/Kiranism/next-shadcn-dashboard-starter/tree/main/.agents/skills/kiranism-shadcn-dashboard -g -y

不安装直接使用

npx skills use Kiranism/next-shadcn-dashboard-starter@kiranism-shadcn-dashboard

指定 Agent (Claude Code)

npx skills add Kiranism/next-shadcn-dashboard-starter --skill kiranism-shadcn-dashboard -a claude-code -g -y

安装 repo 全部 skill

npx skills add Kiranism/next-shadcn-dashboard-starter --all -g -y

预览 repo 内 skill

npx skills add Kiranism/next-shadcn-dashboard-starter --list

SKILL.md

Frontmatter
{
    "name": "kiranism-shadcn-dashboard",
    "description": "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."
}

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 + mutation opts src/features/<name>/api/queries.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) — one object for reads (keys + queryOptions) and one for writes (mutationOptions with only mutationFn). Same shape as todosQueries / todosMutations in TanStack's official Next.js optimistic updates example:

import { mutationOptions, queryOptions } from '@tanstack/react-query';
import { createOrder, deleteOrder, getOrderById, getOrders, updateOrder } from './service';
import type { Order, OrderFilters, OrderMutationPayload } from './types';

export type { Order };

export const orderQueries = {
  all: () => ['orders'] as const,
  list: (filters: OrderFilters) =>
    queryOptions({
      queryKey: [...orderQueries.all(), 'list', filters] as const,
      queryFn: () => getOrders(filters)
    }),
  detail: (id: number) =>
    queryOptions({
      queryKey: [...orderQueries.all(), 'detail', id] as const,
      queryFn: () => getOrderById(id)
    })
};

export const orderMutations = {
  create: () =>
    mutationOptions({
      mutationFn: (data: OrderMutationPayload) => createOrder(data)
    }),
  update: () =>
    mutationOptions({
      mutationFn: ({ id, values }: { id: number; values: OrderMutationPayload }) =>
        updateOrder(id, values)
    }),
  delete: () =>
    mutationOptions({
      mutationFn: (id: number) => deleteOrder(id)
    })
};

No callbacks in orderMutations. The component owns everything that happens after a mutation (invalidation, toast, navigation) in one useMutation block. Putting onSuccess here breaks as soon as a component adds its own: useMutation({ ...options, onSuccess }) is plain object spread, so the component's onSuccess replaces this one. See references/query-abstractions.md.

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.length > 0 && { sort: JSON.stringify(sort) })
  };

  const queryClient = getQueryClient();
  void queryClient.prefetchQuery(orderQueries.list(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/data-table-parsers';
import { useDataTable } from '@/hooks/use-data-table';
import { DataTable } from '@/components/data-table/data-table';
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar';
import { Skeleton } from '@/components/ui/skeleton';
import { ordersQueryOptions } from '../../api/queries';
import { columns } from './columns';

export function OrderTable() {
  const [params] = useQueryStates({
    page: parseAsInteger.withDefault(1),
    perPage: parseAsInteger.withDefault(10),
    name: parseAsString,
    sort: getSortingStateParser().withDefault([]) // same parser as searchParamsCache
  });

  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(orderQueries.list(filters));

  const { table } = useDataTable({
    data: data.items,
    columns,
    pageCount: Math.ceil(data.total_items / params.perPage),
    shallow: true,
    debounceMs: 500,
    initialState: { columnPinning: { start: [], end: ['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. Type columns as ColumnDef<DataTableFeatures, Order>[] (TanStack Table v9; DataTableFeatures from @/lib/data-table-features types meta).

export const columns: ColumnDef<DataTableFeatures, Order>[] = [
  {
    id: 'customer',
    accessorKey: 'customer',
    header: ({ column }) => <DataTableColumnHeader column={column} label='Customer' />,
    meta: { label: 'Customer', placeholder: 'Search...', variant: 'text', icon: Icons.text },
    enableColumnFilter: true
  },
  {
    id: 'status',
    accessorKey: 'status',
    header: ({ column }) => <DataTableColumnHeader column={column} label='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 { orderMutations, orderQueries } from '../../api/queries';

export const CellAction: React.FC<{ data: Order }> = ({ data }) => {
  const [deleteOpen, setDeleteOpen] = useState(false);
  const queryClient = useQueryClient();
  const deleteMutation = useMutation({
    ...orderMutations.delete(),
    onSuccess: () => {
      toast.success('Deleted');
      setDeleteOpen(false);
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: orderQueries.all() })
  });
  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 { orderQueries } 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(orderQueries.detail(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:

  1. Schema — Zod schema + inferred type in schemas/<name>.ts
  2. Form component — useAppForm({ defaultValues, validators: { onSubmit: schema }, onSubmit }) + useFormFields<T>() for typed fields
  3. Mutations — useMutation({ ...orderMutations.create(), onSuccess: () => { toast(); router.push() }, onSettled: () => queryClient.invalidateQueries({ queryKey: orderQueries.all() }) }): spread the mutationFn-only options from api/queries.ts, and keep invalidation and UI callbacks together in the component
  4. View page — client component that checks id === 'new' for create vs useSuspenseQuery(orderQueries.detail(id)) 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 queryClient = useQueryClient();
  const mutation = useMutation({
    ...(isEdit ? orderMutations.update() : orderMutations.create()),
    onSuccess: () => {
      onOpenChange(false);
    },
    onSettled: () => queryClient.invalidateQueries({ queryKey: orderQueries.all() })
  });
  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.

  1. Server: void queryClient.prefetchQuery(options) — fire-and-forget during SSR streaming
  2. Client: useSuspenseQuery(options) — picks up dehydrated data, suspends until resolved
  3. HydrationBoundary + dehydrate: bridges server cache → client cache
  4. 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 with only a mutationFn (in queries.ts), spread at the call site; the component owns invalidation and UI:

// In queries.ts — shared config, no callbacks
export const orderMutations = {
  create: () => mutationOptions({ mutationFn: (data: OrderMutationPayload) => createOrder(data) })
};

// In component — everything that happens after the mutation, in one block
const queryClient = useQueryClient();
const mutation = useMutation({
  ...orderMutations.create(),
  onSuccess: () => toast.success('Created'),
  onSettled: () => queryClient.invalidateQueries({ queryKey: orderQueries.all() })
});

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 organization
  • permission: string — requires specific Clerk permission
  • role: string — requires specific Clerk role
  • plan: 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:

  1. Create src/styles/themes/<name>.css with OKLCH color tokens + @theme inline block
  2. Import in src/styles/theme.css
  3. Register in THEMES array in src/components/themes/theme.config.ts
  4. (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 + useSuspenseQuery on client
  • API layer — types.ts → service.ts → queries.ts per feature; queries.ts exports entityQueries (keys + queryOptions) and entityMutations (mutationOptions, mutationFn only); components spread them and invalidate with useQueryClient() in onSettled; components never import mock APIs directly
  • nuqs — searchParamsCache on server, useQueryStates on client with shallow: true
  • Icons — only from @/components/icons, never from @tabler/icons-react directly
  • Forms — useAppForm + useFormFields<T>() from @/components/ui/tanstack-form
  • Page headers — PageContainer props, never import <Heading> manually
  • Sort parser — use getSortingStateParser from @/lib/data-table-parsers (same parser as useDataTable)
  • Formatting — single quotes, JSX single quotes, no trailing comma, 2-space indent

版本历史

  • ddb106a 当前 2026-11-03 03:00

    重构API层以遵循TanStack Next.js最佳实践,移除mutations.ts并优化查询客户端配置,解决状态更新bug;升级TanStack Table至v9。

  • 06e83c0 2026-07-25 08:23

同 Skill 集合

.agents/skills/frontend-design/SKILL.md
.agents/skills/migrate-radix-to-base/SKILL.md
.agents/skills/next-best-practices/SKILL.md
.agents/skills/shadcn/SKILL.md
.agents/skills/skill-creator/SKILL.md
.agents/skills/tanstack-form/SKILL.md
.agents/skills/tanstack-query/SKILL.md
.agents/skills/vercel-composition-patterns/SKILL.md
.agents/skills/vercel-react-best-practices/SKILL.md
.agents/skills/web-design-guidelines/SKILL.md
.claude/skills/frontend-design/SKILL.md
.claude/skills/next-best-practices/SKILL.md
.claude/skills/skill-creator/SKILL.md
.claude/skills/vercel-composition-patterns/SKILL.md
.claude/skills/vercel-react-best-practices/SKILL.md
.claude/skills/web-design-guidelines/SKILL.md
.agents/skills/find-skills/SKILL.md
.agents/skills/improve/SKILL.md
.claude/skills/find-skills/SKILL.md
.claude/skills/kiranism-shadcn-dashboard/SKILL.md

元信息

文件数
0
版本
ddb106a
Hash
6d5756a4
收录时间
2026-07-25 08:23

首页 - Wiki
Copyright © 2011-2026 iteam. Current version is 2.156.0. UTC+08:00, 2026-11-06 02:07
浙ICP备14020137号-1