Next.js Full-Stack — Windsurf Rules
Windsurf rules for full-stack Next.js work using end-to-end Cascade workflows.
Install path
Use this file for each supported tool in your project.
- Windsurf: Save as
.windsurfrulesin your project at.windsurfrules.
Configuration
.windsurfrules
1# Next.js Full-Stack — Windsurf Rules23Full-stack Next.js App Router application. Server Components by default. Server Actions for mutations. Cascade: think in end-to-end flows — a feature touches DB schema, server logic, validation, and UI. Wire them together.45## Quick Reference67| Area | Convention |8|---|---|9| Framework | Next.js 14+ (App Router only) |10| Language | TypeScript, strict mode |11| Styling | Tailwind CSS + `cn()` utility |12| Package manager | pnpm |13| Components | Server Components default; `"use client"` pushed to leaf nodes |14| Data fetching | Server Components + `fetch` with caching / `unstable_cache` |15| Mutations | Server Actions with Zod validation |16| Database | Prisma (or Drizzle — check `package.json`) |17| Auth | NextAuth / Auth.js or Clerk (check existing setup) |18| Validation | Zod — shared schemas between client and server |19| Testing | Vitest + React Testing Library |20| Forms | `useActionState` + Server Actions (or React Hook Form if present) |2122## Project Structure2324```25├── app/26│ ├── layout.tsx # Root layout (Server Component)27│ ├── page.tsx # Home page28│ ├── globals.css # Tailwind directives29│ ├── (marketing)/ # Route group — public pages30│ │ ├── page.tsx31│ │ └── pricing/page.tsx32│ ├── (app)/ # Route group — authenticated33│ │ ├── layout.tsx # Auth guard layout34│ │ ├── dashboard/page.tsx35│ │ └── settings/page.tsx36│ └── api/ # Route Handlers (webhooks, OAuth callbacks only)37│ └── webhooks/stripe/route.ts38├── components/39│ ├── ui/ # Primitives (button, input, card, dialog)40│ └── [feature]/ # Feature-scoped (invoice-table, user-form)41├── lib/42│ ├── utils.ts # cn() helper43│ ├── db.ts # Prisma/Drizzle client (server-only)44│ ├── auth.ts # Auth helpers (server-only)45│ ├── validations/ # Zod schemas (shared)46│ └── actions/ # Server Actions grouped by domain47├── hooks/ # Client-side React hooks48├── types/ # Shared TypeScript types49├── prisma/50│ └── schema.prisma # Database schema51└── tailwind.config.ts52```5354## Full-Stack Flow Pattern5556When Cascade implements a feature, it touches multiple layers. Here's the expected flow:5758### 1. Schema → 2. Validation → 3. Server Action → 4. UI5960**Example: Creating an invoice**6162**Step 1: Database schema** (if the model doesn't exist)6364```prisma65model Invoice {66 id String @id @default(cuid())67 number String @unique68 clientId String69 client Client @relation(fields: [clientId], references: [id])70 items InvoiceItem[]71 status InvoiceStatus @default(DRAFT)72 total Decimal @db.Decimal(10, 2)73 dueDate DateTime74 createdAt DateTime @default(now())75 updatedAt DateTime @updatedAt76}7778enum InvoiceStatus {79 DRAFT80 SENT81 PAID82 OVERDUE83 CANCELLED84}85```8687After schema changes, run: `pnpm prisma migrate dev --name <description>` then `pnpm prisma generate`.8889**Step 2: Validation schema** (`lib/validations/invoice.ts`)9091```typescript92import { z } from "zod"9394export const invoiceItemSchema = z.object({95 description: z.string().min(1, "Description is required"),96 quantity: z.number().int().positive(),97 unitPrice: z.number().positive(),98})99100export const createInvoiceSchema = z.object({101 clientId: z.string().min(1, "Client is required"),102 items: z.array(invoiceItemSchema).min(1, "At least one line item required"),103 dueDate: z.coerce.date().refine(104 (date) => date > new Date(),105 "Due date must be in the future"106 ),107 notes: z.string().max(500).optional(),108})109110export type CreateInvoiceInput = z.infer<typeof createInvoiceSchema>111```112113**Step 3: Server Action** (`lib/actions/invoice.ts`)114115```typescript116"use server"117118import { z } from "zod"119import { revalidatePath } from "next/cache"120import { db } from "@/lib/db"121import { getCurrentUser } from "@/lib/auth"122import { createInvoiceSchema } from "@/lib/validations/invoice"123124type ActionResult =125 | { success: true; invoiceId: string }126 | { success: false; error: string; fieldErrors?: Record<string, string[]> }127128export async function createInvoice(formData: FormData): Promise<ActionResult> {129 const user = await getCurrentUser()130 if (!user) {131 return { success: false, error: "Unauthorized" }132 }133134 const raw = {135 clientId: formData.get("clientId"),136 items: JSON.parse(formData.get("items") as string),137 dueDate: formData.get("dueDate"),138 notes: formData.get("notes"),139 }140141 const parsed = createInvoiceSchema.safeParse(raw)142 if (!parsed.success) {143 return {144 success: false,145 error: "Validation failed",146 fieldErrors: parsed.error.flatten().fieldErrors,147 }148 }149150 const { items, ...invoiceData } = parsed.data151 const total = items.reduce(152 (sum, item) => sum + item.quantity * item.unitPrice, 0153 )154155 const invoice = await db.invoice.create({156 data: {157 ...invoiceData,158 number: await generateInvoiceNumber(),159 total,160 items: { create: items },161 userId: user.id,162 },163 })164165 revalidatePath("/invoices")166 return { success: true, invoiceId: invoice.id }167}168```169170**Step 4: UI component** (`components/invoice/create-invoice-form.tsx`)171172```tsx173"use client"174175import { useActionState } from "react"176import { createInvoice } from "@/lib/actions/invoice"177import { Button } from "@/components/ui/button"178179export function CreateInvoiceForm({ clients }: { clients: Client[] }) {180 const [state, formAction, isPending] = useActionState(createInvoice, null)181182 return (183 <form action={formAction} className="space-y-6">184 {state?.success === false && (185 <div className="rounded-md bg-red-50 p-3 text-sm text-red-700">186 {state.error}187 </div>188 )}189190 <select name="clientId" required className="w-full rounded-md border p-2">191 <option value="">Select client</option>192 {clients.map((c) => (193 <option key={c.id} value={c.id}>{c.name}</option>194 ))}195 </select>196197 {/* Line items, due date, etc. */}198199 <Button type="submit" disabled={isPending}>200 {isPending ? "Creating..." : "Create Invoice"}201 </Button>202 </form>203 )204}205```206207**Step 5: Page wiring** (`app/(app)/invoices/new/page.tsx`)208209```tsx210import { db } from "@/lib/db"211import { CreateInvoiceForm } from "@/components/invoice/create-invoice-form"212213export default async function NewInvoicePage() {214 const clients = await db.client.findMany({215 orderBy: { name: "asc" },216 select: { id: true, name: true },217 })218219 return (220 <div className="mx-auto max-w-2xl py-8">221 <h1 className="text-2xl font-bold">New Invoice</h1>222 <CreateInvoiceForm clients={clients} />223 </div>224 )225}226```227228## Server Components vs Client Components229230**Server Component (default — no directive):**231- Fetches data directly (DB, APIs)232- Has access to server-only modules (`db`, `auth`, env vars)233- Cannot use hooks, event handlers, or browser APIs234235**Client Component (`"use client"`):**236- Handles interactivity (forms, state, effects)237- Push `"use client"` as LOW as possible — wrap only the interactive piece238239```tsx240// ✅ Server Component fetches → passes to tiny client island241import { db } from "@/lib/db"242import { LikeButton } from "./like-button" // "use client" inside243244export async function PostCard({ id }: { id: string }) {245 const post = await db.post.findUnique({ where: { id } })246 if (!post) return null247248 return (249 <article className="rounded-lg border p-4">250 <h2 className="font-semibold">{post.title}</h2>251 <p className="text-zinc-600">{post.excerpt}</p>252 <LikeButton postId={id} initialCount={post.likes} />253 </article>254 )255}256```257258## Data Fetching & Caching259260```typescript261// ✅ Cached database query with tag-based revalidation262import { unstable_cache } from "next/cache"263264const getInvoices = unstable_cache(265 async (userId: string) => {266 return db.invoice.findMany({267 where: { userId },268 orderBy: { createdAt: "desc" },269 include: { client: { select: { name: true } } },270 })271 },272 ["invoices"],273 { tags: ["invoices"], revalidate: 60 }274)275276// In Server Action after mutation:277revalidateTag("invoices")278```279280**Rules:**281- Fetch in Server Components. Pass data down as props.282- Use `loading.tsx` and `<Suspense>` for streaming.283- Route Handlers (`app/api/`) are for webhooks and OAuth callbacks — not internal data fetching.284285## Database Patterns286287### Prisma Conventions288289- Keep `db` instance in `lib/db.ts` — import as `import { db } from "@/lib/db"`.290- Use `select` to fetch only needed fields. Avoid `include` with large relations.291- Wrap multi-step operations in `db.$transaction()`.292- Optimistic updates: check `updatedAt` or version field before writing.293294```typescript295// ✅ Transaction for multi-step mutation296async function markInvoicePaid(invoiceId: string, paymentId: string) {297 return db.$transaction(async (tx) => {298 const invoice = await tx.invoice.update({299 where: { id: invoiceId, status: "SENT" },300 data: { status: "PAID" },301 })302 await tx.payment.create({303 data: { invoiceId, externalId: paymentId, amount: invoice.total },304 })305 return invoice306 })307}308```309310## Error Handling311312- **Server Actions:** return `{ success: false, error: "..." }` — never throw.313- **Server Components:** use `error.tsx` boundaries and `notFound()`.314- **Client Components:** use Error Boundaries or try/catch in event handlers.315316```tsx317// app/(app)/invoices/[id]/error.tsx318"use client"319320export default function InvoiceError({321 error,322 reset,323}: {324 error: Error325 reset: () => void326}) {327 return (328 <div className="rounded-md bg-red-50 p-6 text-center">329 <h2 className="text-lg font-semibold text-red-800">Something went wrong</h2>330 <p className="mt-2 text-sm text-red-600">{error.message}</p>331 <button onClick={reset} className="mt-4 rounded bg-red-600 px-4 py-2 text-white">332 Try again333 </button>334 </div>335 )336}337```338339## Tailwind Conventions340341- Use `cn()` from `lib/utils.ts` for conditional classes.342- Mobile-first responsive: `sm:`, `md:`, `lg:`.343- Always provide `dark:` variants for colors and borders.344- No arbitrary values (`w-[347px]`) — extend the theme in `tailwind.config.ts`.345- Component variants via `cn()` with conditional objects — not ternary strings.346347## Auth Guard Pattern348349```tsx350// app/(app)/layout.tsx — protects all (app) routes351import { redirect } from "next/navigation"352import { getCurrentUser } from "@/lib/auth"353354export default async function AppLayout({ children }: { children: React.ReactNode }) {355 const user = await getCurrentUser()356 if (!user) redirect("/login")357358 return <>{children}</>359}360```361362## File & Naming Conventions363364| What | Convention |365|---|---|366| Files | kebab-case: `create-invoice-form.tsx`, `auth-utils.ts` |367| Components | PascalCase exports: `CreateInvoiceForm` |368| Server Actions | camelCase functions: `createInvoice`, `updateInvoiceStatus` |369| Zod schemas | camelCase with Schema suffix: `createInvoiceSchema` |370| Route files | Next.js conventions: `page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx` |371| Barrel exports | Avoid — import from source module directly |372373## Cascade Flow Guidance374375When implementing a feature end-to-end:3763771. **Start with the data model.** Does the schema support this feature? Add/modify Prisma models first.3782. **Define validation.** Write the Zod schema for input validation. This is the contract between client and server.3793. **Implement the server action or data query.** This is the business logic layer.3804. **Build the UI last.** Server Component page → Client Component for interactivity.3815. **Add `loading.tsx`** for any page that fetches data.3826. **Revalidate caches** after mutations with `revalidatePath` or `revalidateTag`.383384When modifying an existing feature, trace the full flow before editing: schema → validation → action → UI. A change in one layer usually requires changes in others.385386## Commands387388```bash389pnpm dev # Dev server with hot reload390pnpm build # Production build (type-checks)391pnpm lint # ESLint392pnpm test # Vitest393pnpm prisma studio # Visual DB browser394pnpm prisma migrate dev --name x # Create + apply migration395pnpm prisma generate # Regenerate Prisma client396pnpm prisma db seed # Run seed script397```398
Community feedback
0 found this helpful
Works with: