CtrlK
BlogDocsLog inGet started
Tessl Logo

product-factory/architecture

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

Overview
Skills
Evals
Files
name:
architecture
description:
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.

Architecture: Next.js App Router + TypeScript + Tailwind + shadcn/ui + Drizzle

Stack Identity

LayerTechnology
FrameworkNext.js 15+ (App Router)
LanguageTypeScript (strict mode)
StylingTailwind CSS (utility-first)
Componentsshadcn/ui (Radix primitives + Tailwind variants)
DatabaseDrizzle ORM (schema-as-code, SQL-first)
ValidationZod (schema validation at all boundaries)
Server StateTanStack Query v5
Client StateZustand or React Context

Server Components by Default

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:

  • Push "use client" as far down the tree as possible — leaf components, not pages
  • Data fetching belongs in Server Components or Server Actions, not client-side useEffect
  • Pass server data as props to client components

Server Actions for Mutations

Use "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.


Canonical Folder Structure

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 CSS

Query Key Factories

Centralise 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 })     // everything

Suspense Boundaries

Use 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.


Core Principles

1. Feature Cohesion

Co-locate everything a feature needs. Don't scatter related logic across the codebase.

2. Unidirectional Data Flow

  • Server data → Server Components or TanStack Query
  • Mutations → Server Actions (preferred) or API routes
  • UI state → local useState or Zustand
  • Forms → react-hook-form + Zod resolver

3. Drizzle Schema Organisation

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.$inferInsert

4. Zod at Every Boundary

Validate 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.

5. Path Aliases

Always use @/ for imports from src/. Never use relative paths beyond one level.

6. TypeScript Strictness

Always enabled. No any. Enable noUncheckedIndexedAccess and exactOptionalPropertyTypes.


Decision Rules

ScenarioDecision
New reusable UI primitivecomponents/common/
Feature-specific UIfeatures/[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 utillib/utils.ts or lib/[domain].ts
Type used in one featurefeatures/[feature]/types.ts
Type used across featurestypes/
New DB tablelib/db/schema/[domain].ts + drizzle-kit generate
Query keysfeatures/[feature]/queries.ts key factory

Anti-Patterns

  • Putting business logic in components — extract to Server Actions, hooks, or lib functions
  • Making entire pages "use client" — push client boundaries down to leaf components
  • Fetching data in useEffect — use Server Components or TanStack Query
  • Barrel exports (index.ts) that re-export everything — leads to circular imports
  • Large monolithic components — split at 300 lines
  • Scattering query keys as string literals — use a key factory

Install with Tessl CLI

npx tessl i product-factory/architecture
Workspace
product-factory
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
product-factory/architecture badge