AgentSkillsCN

web-frontend

Next.js 应用路由、Server/Client 组件、shadcn/ui 设计模式、Tailwind CSS 规范

SKILL.md
--- frontmatter
name: web-frontend
description: Next.js App Router, Server/Client components, shadcn/ui patterns, Tailwind CSS conventions

Web Frontend Skill

Core Principles

  1. Server Components by Default - Use Client Components only when needed (hooks, events, browser APIs)
  2. Type-Safe API Calls - Always use tRPC for data fetching
  3. Semantic Colors - Use design tokens from globals.css
  4. Form Validation - React Hook Form + Zod for complex forms
  5. Protected Routes - Wrap authenticated pages with AuthGuard
  6. Error Boundaries - Use error.tsx for automatic error handling

Patterns to Follow

Pattern 1: Server vs Client Component Decision

Decision Tree:

code
Does it use hooks? (useState, useEffect, etc.) → Client Component
Does it handle events? (onClick, onChange, etc.) → Client Component
Does it use browser APIs? (window, localStorage, etc.) → Client Component
Does it use third-party client libraries? → Client Component
Otherwise → Server Component (default)

Examples:

tsx
// ✅ Server Component - Data fetching
export default async function ActivityPage({
  params,
}: {
  params: { id: string };
}) {
  const supabase = createClient();
  const { data: activity } = await supabase
    .from("activities")
    .select("*")
    .eq("id", params.id)
    .single();

  return <ActivityDetail activity={activity} />;
}

// ✅ Client Component - Interactive
("use client");

export function ActivityChart({ data }: Props) {
  const [metric, setMetric] = useState("heartRate");

  return (
    <div>
      <select onChange={(e) => setMetric(e.target.value)}>
        <option value="heartRate">Heart Rate</option>
      </select>
      <Chart data={data} metric={metric} />
    </div>
  );
}

Pattern 2: tRPC Query Integration

When to use: Client-side data fetching Why: Type-safe, automatic cache management

tsx
"use client";

import { trpc } from "@/lib/trpc";

export function ActivitiesList() {
  const { data, isLoading, error, refetch } = trpc.activities.list.useQuery(
    { limit: 20, offset: 0 },
    {
      staleTime: 5 * 60 * 1000, // 5 minutes
      refetchOnWindowFocus: false,
    },
  );

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorAlert message={error.message} />;

  return (
    <div>
      {data?.map((activity) => (
        <ActivityCard key={activity.id} activity={activity} />
      ))}
    </div>
  );
}

Pattern 3: tRPC Mutation with Optimistic Updates

When to use: Create/update/delete operations Why: Instant UI feedback, automatic rollback on error

tsx
const utils = trpc.useUtils();

const mutation = trpc.activities.update.useMutation({
  onMutate: async (updatedActivity) => {
    await utils.activities.list.cancel();
    const previousActivities = utils.activities.list.getData();

    utils.activities.list.setData(undefined, (old) =>
      old?.map((act) =>
        act.id === updatedActivity.id ? { ...act, ...updatedActivity } : act,
      ),
    );

    return { previousActivities };
  },
  onError: (err, vars, context) => {
    utils.activities.list.setData(undefined, context?.previousActivities);
  },
  onSettled: () => {
    utils.activities.list.invalidate();
  },
});

Pattern 4: Complex Forms with React Hook Form

When to use: Forms with validation, field arrays, dynamic fields Why: Type-safe, automatic error handling, Zod integration

tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { activitySchema } from "@repo/core/schemas";

export function ActivityForm() {
  const form = useForm({
    resolver: zodResolver(activitySchema),
    defaultValues: { name: "", type: "run", distance: 0 },
  });

  const mutation = trpc.activities.create.useMutation({
    onSuccess: () => toast.success("Activity created"),
    onError: (error) => {
      if (error.data?.zodError) {
        const fieldErrors = error.data.zodError.fieldErrors;
        Object.entries(fieldErrors).forEach(([field, messages]) => {
          form.setError(field as any, { message: messages?.[0] });
        });
      }
    },
  });

  return (
    <form onSubmit={form.handleSubmit((data) => mutation.mutate(data))}>
      <input {...form.register("name")} />
      {form.formState.errors.name && (
        <span className="text-destructive">
          {form.formState.errors.name.message}
        </span>
      )}
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? "Creating..." : "Create"}
      </button>
    </form>
  );
}

Pattern 5: Protected Routes with AuthGuard

When to use: Dashboard pages, user-specific content Why: Automatic redirect to login, loading states

tsx
// Layout for protected routes
export default function DashboardLayout({ children }: Props) {
  return (
    <AuthGuard>
      <div className="min-h-screen bg-background">
        <Sidebar />
        <main className="ml-64">{children}</main>
      </div>
    </AuthGuard>
  );
}

// AuthGuard component
("use client");

export function AuthGuard({ children }: { children: React.ReactNode }) {
  const { data: session, isLoading } = useSession();

  if (isLoading) return <LoadingScreen />;
  if (!session) redirect("/login");

  return <>{children}</>;
}

Pattern 6: shadcn/ui Component Usage

When to use: UI components needing consistent styling Why: Pre-styled, accessible, customizable

tsx
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

<Card>
  <CardHeader>
    <CardTitle>Activity Details</CardTitle>
  </CardHeader>
  <CardContent>
    <Button variant="default">Save</Button>
    <Button variant="outline">Cancel</Button>
  </CardContent>
</Card>;

Anti-Patterns to Avoid

Anti-Pattern 1: Using Hooks in Server Components

tsx
// ❌ BAD
export default function Page() {
  const [state, setState] = useState(0); // Error!
  return <div>{state}</div>;
}

// ✅ CORRECT
("use client");

export default function Page() {
  const [state, setState] = useState(0);
  return <div>{state}</div>;
}

Anti-Pattern 2: Fetching Data in Client Components

tsx
// ❌ BAD
"use client";

export default function Page() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetch("/api/data")
      .then((r) => r.json())
      .then(setData);
  }, []);
}

// ✅ CORRECT - Use tRPC
("use client");

export default function Page() {
  const { data } = trpc.getData.useQuery();
}

// ✅ OR use Server Component
export default async function Page() {
  const data = await fetchData();
  return <ClientComponent data={data} />;
}

Anti-Pattern 3: Ignoring Cache Invalidation

tsx
// ❌ BAD
const mutation = trpc.activities.create.useMutation({
  onSuccess: () => {
    toast.success("Created");
    // Forgot to invalidate cache!
  },
});

// ✅ CORRECT
const utils = trpc.useUtils();

const mutation = trpc.activities.create.useMutation({
  onSuccess: () => {
    utils.activities.list.invalidate();
    toast.success("Created");
  },
});

File Organization

code
apps/web/
├── app/
│   ├── (marketing)/       # Public pages
│   │   ├── page.tsx       # Landing page
│   │   └── _layout.tsx
│   ├── (dashboard)/       # Protected dashboard
│   │   ├── activities/
│   │   │   ├── page.tsx
│   │   │   └── [id]/page.tsx
│   │   └── _layout.tsx
│   ├── api/               # API routes
│   └── globals.css
├── components/
│   ├── ui/                # shadcn/ui components
│   └── shared/            # Shared components
└── lib/
    ├── trpc.ts            # tRPC client
    └── utils.ts

Naming Conventions

  • Components: PascalCaseActivityCard.tsx
  • Utilities: camelCaseformatDate.ts
  • Hooks: camelCase with useuseAuth.ts
  • Route Segments: kebab-caseactivity-detail/
  • API Routes: kebab-casewebhook-handler/

Common Scenarios

Scenario 1: Create Protected Dashboard Page

tsx
// app/(dashboard)/activities/page.tsx
import { AuthGuard } from "@/components/AuthGuard";
import { ActivitiesList } from "@/components/ActivitiesList";

export default function ActivitiesPage() {
  return (
    <AuthGuard>
      <div className="container py-8">
        <h1 className="text-3xl font-bold mb-6">Activities</h1>
        <ActivitiesList />
      </div>
    </AuthGuard>
  );
}

Scenario 2: OAuth Callback Handler

tsx
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const code = request.nextUrl.searchParams.get("code");

  if (!code) {
    return NextResponse.redirect("/login?error=no_code");
  }

  // Exchange code for tokens
  const tokens = await exchangeCodeForTokens(code);

  // Store tokens
  await storeTokens(userId, tokens);

  return NextResponse.redirect("/dashboard");
}

Dependencies

Required:

  • next v15+
  • react v19+
  • @tanstack/react-query v5
  • @trpc/client, @trpc/server, @trpc/react-query
  • tailwindcss v4
  • zod

Optional:

  • react-hook-form + @hookform/resolvers
  • sonner (toast notifications)

Testing Requirements

  • Test Server Components with async data
  • Test Client Components with React Testing Library
  • Mock tRPC with msw-trpc
  • Test forms with user event simulation
  • Test error boundaries

Checklist

  • "use client" directive on interactive components
  • tRPC queries for data fetching
  • Cache invalidation on mutations
  • Protected routes wrapped with AuthGuard
  • Forms use React Hook Form + Zod
  • Error boundaries implemented
  • Loading states handled
  • Semantic colors from design tokens

Related Skills

Version History

  • 1.0.0 (2026-01-21): Initial version

Next Review: 2026-02-21

Web tRPC Setup

When to Use

  • User needs to add data fetching to a component
  • User wants to create a new tRPC query or mutation
  • User needs to set up React Query with tRPC
  • User asks to add form submission with tRPC

What This Skill Does

  1. Determines if query or mutation is needed
  2. Creates proper tRPC procedure calls
  3. Handles loading, error, and success states
  4. Sets up proper React Query options
  5. Adds cache invalidation for mutations
  6. Implements proper TypeScript typing

Query Pattern

typescript
"use client";

import { trpc } from "@/lib/trpc";

export function useActivities() {
  const { data, isLoading, error, refetch } = trpc.activities.list.useQuery(
    { limit: 20, offset: 0 },
    {
      staleTime: 5 * 60 * 1000,
      refetchOnWindowFocus: false,
    },
  );

  return { data, isLoading, error, refetch };
}

Mutation Pattern

typescript
"use client";

import { trpc } from "@/lib/trpc";
import { toast } from "sonner";

export function useCreateActivity() {
  const utils = trpc.useUtils();

  const mutation = trpc.activities.create.useMutation({
    onSuccess: () => {
      utils.activities.list.invalidate();
      toast.success("Activity created");
    },
    onError: (error) => {
      toast.error(error.message);
    },
  });

  return mutation;
}

Error Handling

typescript
if (isLoading) return <Skeleton />;
if (error) return <ErrorAlert message={error.message} onRetry={refetch} />;

Loading States

typescript
import { Skeleton } from '@/components/ui/skeleton';

function ActivityList() {
  const { data, isLoading } = trpc.activities.list.useQuery();

  if (isLoading) {
    return (
      <View className="p-4">
        {Array.from({ length: 5 }).map((_, i) => (
          <Skeleton key={i} className="h-24 mb-4" />
        ))}
      </View>
    );
  }

  return <ActivityListView activities={data} />;
}

Cache Invalidation

typescript
// Invalidate after mutation
utils.activities.list.invalidate();

// Invalidate specific item
utils.activities.getById.invalidate({ id });