shadcn-stack
4
总安装量
4
周安装量
#51804
全站排名
安装命令
npx skills add https://github.com/5dlabs/cto --skill shadcn-stack
Agent 安装分布
claude-code
3
opencode
2
codex
2
gemini-cli
2
trae
1
Skill 文档
shadcn Stack
Modern Next.js architecture optimized for server-side rendering, SEO, and progressive enhancement.
Core Technologies
| Library | Purpose | Install |
|---|---|---|
| Next.js 15 | Full-stack React framework | next |
| shadcn/ui | Accessible UI components | npx shadcn@latest add [component] |
| React Query | Client-side data caching | @tanstack/react-query |
| React Hook Form | Form state management | react-hook-form |
| Effect | Type-safe validation & errors | effect, @hookform/resolvers |
| Tailwind CSS | Utility-first styling | tailwindcss |
Next.js App Router Patterns
File-Based Routing
app/
âââ layout.tsx # Root layout
âââ page.tsx # Home page (/)
âââ loading.tsx # Loading UI
âââ error.tsx # Error boundary
âââ dashboard/
â âââ layout.tsx # Dashboard layout
â âââ page.tsx # /dashboard
â âââ settings/
â âââ page.tsx # /dashboard/settings
âââ api/
âââ users/
âââ route.ts # API route
Server Components (Default)
// app/users/page.tsx - Server Component by default
import { getUsers } from '@/lib/db';
export default async function UsersPage() {
const users = await getUsers(); // Direct database access
return (
<div>
<h1>Users</h1>
<UserList users={users} />
</div>
);
}
Client Components
// components/user-search.tsx
'use client';
import { useState } from 'react';
import { Input } from '@/components/ui/input';
export function UserSearch({ onSearch }: { onSearch: (query: string) => void }) {
const [query, setQuery] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
onSearch(e.target.value);
};
return (
<Input
value={query}
onChange={handleChange}
placeholder="Search users..."
/>
);
}
Server Actions
Define Server Actions
// app/actions/users.ts
'use server';
import { revalidatePath } from 'next/cache';
import { Schema, Effect } from 'effect';
import { db } from '@/lib/db';
const CreateUserSchema = Schema.Struct({
name: Schema.String.pipe(Schema.minLength(2)),
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/)),
role: Schema.Literal('admin', 'user', 'guest'),
});
export async function createUser(formData: FormData) {
const validated = Schema.decodeUnknownSync(CreateUserSchema)({
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
});
await db.user.create({ data: validated });
revalidatePath('/users');
}
export async function deleteUser(id: string) {
await db.user.delete({ where: { id } });
revalidatePath('/users');
}
Use in Components
// app/users/create/page.tsx
import { createUser } from '@/app/actions/users';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
export default function CreateUserPage() {
return (
<form action={createUser}>
<Input name="name" placeholder="Name" required />
<Input name="email" type="email" placeholder="Email" required />
<select name="role">
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<Button type="submit">Create User</Button>
</form>
);
}
With useFormStatus for Loading States
'use client';
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create User'}
</Button>
);
}
shadcn/ui Components
Installation
npx shadcn@latest init
npx shadcn@latest add button card input form table dialog
Component Usage
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export function UserCard({ user }: { user: User }) {
return (
<Card>
<CardHeader>
<CardTitle>{user.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{user.email}</p>
</CardContent>
<CardFooter>
<Button variant="outline">Edit</Button>
<Button variant="destructive">Delete</Button>
</CardFooter>
</Card>
);
}
Dialog Pattern
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
export function CreateUserDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create User</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New User</DialogTitle>
<DialogDescription>
Add a new user to the system.
</DialogDescription>
</DialogHeader>
<CreateUserForm onSuccess={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
React Hook Form + Effect Schema
'use client';
import { useForm } from 'react-hook-form';
import { effectTsResolver } from '@hookform/resolvers/effect-ts';
import { Schema } from 'effect';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
const formSchema = Schema.Struct({
name: Schema.String.pipe(Schema.minLength(2, { message: () => 'Name must be at least 2 characters' })),
email: Schema.String.pipe(Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/, { message: () => 'Invalid email address' })),
});
type FormValues = Schema.Schema.Type<typeof formSchema>;
export function CreateUserForm({ onSuccess }: { onSuccess: () => void }) {
const form = useForm<FormValues>({
resolver: effectTsResolver(formSchema),
defaultValues: { name: '', email: '' },
});
async function onSubmit(values: FormValues) {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(values),
});
if (response.ok) {
onSuccess();
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating...' : 'Create'}
</Button>
</form>
</Form>
);
}
React Query for Client-Side Data
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
return response.json();
},
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newUser: CreateUserInput) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
});
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
Query Provider Setup
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
shadcn Table with Server Data
// app/users/page.tsx
import { getUsers } from '@/lib/db';
import { UsersTable } from './users-table';
export default async function UsersPage() {
const users = await getUsers();
return <UsersTable data={users} />;
}
// app/users/users-table.tsx
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { deleteUser } from '@/app/actions/users';
export function UsersTable({ data }: { data: User[] }) {
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((user) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
{user.role}
</Badge>
</TableCell>
<TableCell>
<form action={deleteUser.bind(null, user.id)}>
<Button variant="destructive" size="sm" type="submit">
Delete
</Button>
</form>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
Loading & Error States
Loading UI
// app/users/loading.tsx
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
Error Boundary
// app/users/error.tsx
'use client';
import { Button } from '@/components/ui/button';
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="flex flex-col items-center gap-4 py-16">
<h2 className="text-xl font-semibold">Something went wrong!</h2>
<p className="text-muted-foreground">{error.message}</p>
<Button onClick={reset}>Try again</Button>
</div>
);
}
SEO & Metadata
// app/users/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Users | My App',
description: 'Manage users in the system',
openGraph: {
title: 'Users',
description: 'Manage users in the system',
},
};
export default async function UsersPage() {
// ...
}
Dynamic Metadata
// app/users/[id]/page.tsx
import { Metadata } from 'next';
import { getUser } from '@/lib/db';
type Props = { params: Promise<{ id: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const user = await getUser(id);
return {
title: `${user.name} | My App`,
description: `Profile for ${user.name}`,
};
}
Best Practices
- Server Components by default – Only add ‘use client’ when needed
- Server Actions for mutations – Avoid API routes for form submissions
- Co-locate components – Keep page-specific components in route folders
- Use shadcn primitives – Build on top of existing components
- Effect Schema everywhere – Validate on both client and server
- Streaming with Suspense – Wrap slow components for progressive loading
- Revalidate strategically – Use revalidatePath/revalidateTag after mutations