Apply this skill whenever designing, scaffolding, reviewing, or refactoring the architecture of a Next.js App Router + TypeScript + Tailwind + shadcn + Drizzle application. Triggers on requests like "how should I structure this", "where should this logic live", "scaffold a new feature", "review my folder structure", "plan this feature", "add a new module", or any time you're creating multiple files that need to fit together coherently. Use this skill proactively — do not make ad-hoc structural decisions without consulting it.
90
Quality
90%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
| Layer | Technology |
|---|---|
| Framework | Next.js 15+ (App Router) |
| Language | TypeScript (strict mode) |
| Styling | Tailwind CSS (utility-first) |
| Components | shadcn/ui (Radix primitives + Tailwind variants) |
| Database | Drizzle ORM (schema-as-code, SQL-first) |
| Validation | Zod (schema validation at all boundaries) |
| Server State | TanStack Query v5 |
| Client State | Zustand or React Context |
Components are Server Components unless they need state, effects, or browser APIs. Only add "use client" when required.
// ✅ Server Component (default) — fetches data, ships zero JS
export default async function UsersPage() {
const users = await db.select().from(users)
return <UserList users={users} />
}
// ✅ Client Component — only when interactivity is needed
"use client"
export function SearchFilter({ onSearch }: { onSearch: (q: string) => void }) {
const [query, setQuery] = useState('')
// ...
}Rules:
"use client" as far down the tree as possible — leaf components, not pagesuseEffectUse "use server" functions for in-app mutations. They replace boilerplate API routes and get CSRF protection for free.
// features/users/actions.ts
"use server"
import { z } from 'zod'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(255),
})
export async function createUser(input: unknown) {
const session = await getServerSession()
if (!session) throw new Error('Unauthorised')
const parsed = CreateUserSchema.safeParse(input)
if (!parsed.success) return { error: parsed.error.issues[0].message }
const [user] = await db.insert(users).values(parsed.data).returning()
revalidatePath('/users')
return { data: user }
}When to use REST API routes instead: webhooks, external API consumers, file uploads, streaming responses.
src/
├── app/ # Route segments (App Router)
│ ├── layout.tsx # Root layout (Server Component)
│ ├── global-error.tsx # Root error boundary
│ ├── not-found.tsx # 404 page
│ └── (dashboard)/
│ ├── layout.tsx
│ ├── loading.tsx # Route-level loading state
│ └── [feature]/
│ ├── page.tsx # Server Component — fetch + render
│ ├── loading.tsx # Per-segment loading skeleton
│ ├── error.tsx # Per-segment error boundary
│ └── not-found.tsx
├── components/
│ ├── ui/ # shadcn/ui — treat as managed, prefer wrapping over editing
│ ├── common/ # Shared presentational components
│ └── [feature]/ # Feature-scoped composite components
├── features/ # Feature modules (co-locate everything per feature)
│ └── [feature]/
│ ├── components/ # Feature-specific UI (client components)
│ ├── hooks/ # Feature-specific hooks
│ ├── actions.ts # Server Actions for this feature
│ ├── queries.ts # Query key factory + query options
│ ├── schema.ts # Zod schemas
│ └── types.ts # TypeScript types
├── hooks/ # Shared custom hooks
├── lib/
│ ├── db/
│ │ ├── schema/ # Drizzle table definitions (one file per domain)
│ │ ├── migrations/ # Drizzle-generated migration files
│ │ └── index.ts # DB connection
│ └── utils.ts # cn(), formatters
├── types/ # Global type definitions
└── styles/ # Global CSSCentralise query keys per domain to prevent key drift and simplify invalidation.
// features/users/queries.ts
import { queryOptions } from '@tanstack/react-query'
export const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
}
export const userQueryOptions = (id: string) => queryOptions({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
staleTime: 1000 * 60 * 5,
})
// Invalidation is now precise
queryClient.invalidateQueries({ queryKey: userKeys.lists() }) // all lists
queryClient.invalidateQueries({ queryKey: userKeys.all }) // everythingUse granular <Suspense> boundaries for independent data dependencies so they stream in parallel.
// app/(dashboard)/overview/page.tsx — Server Component
export default function OverviewPage() {
return (
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<StatsSkeleton />}>
<StatsPanel /> {/* fetches stats */}
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed /> {/* fetches activity — independent, streams in parallel */}
</Suspense>
</div>
)
}Every route segment should have a loading.tsx for instant navigation feedback.
Co-locate everything a feature needs. Don't scatter related logic across the codebase.
One file per domain table group. Export table definitions and inferred types together.
// lib/db/schema/users.ts
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
name: varchar('name', { length: 255 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsertValidate at: form submission, Server Action input, API route input, environment variables. Share schemas between client and server — infer TypeScript types from Zod, not the other way around.
Always use @/ for imports from src/. Never use relative paths beyond one level.
Always enabled. No any. Enable noUncheckedIndexedAccess and exactOptionalPropertyTypes.
| Scenario | Decision |
|---|---|
| New reusable UI primitive | components/common/ |
| Feature-specific UI | features/[feature]/components/ |
| Data fetching (page level) | Server Component in page.tsx |
| Data fetching (client interactive) | TanStack Query in features/[feature]/hooks/ |
| Mutation (in-app) | Server Action in features/[feature]/actions.ts |
| Mutation (external consumer) | API route |
| Shared util | lib/utils.ts or lib/[domain].ts |
| Type used in one feature | features/[feature]/types.ts |
| Type used across features | types/ |
| New DB table | lib/db/schema/[domain].ts + drizzle-kit generate |
| Query keys | features/[feature]/queries.ts key factory |
"use client" — push client boundaries down to leaf componentsuseEffect — use Server Components or TanStack Queryindex.ts) that re-export everything — leads to circular importsInstall with Tessl CLI
npx tessl i product-factory/architecture