Next.js + shadcn/ui + React Query Development Guide

Full-stack development guidelines for Next.js 15 with App Router, TypeScript, Tailwind CSS, shadcn/ui components, and TanStack React Query for data fetching.

MMarie Dubois
·Nov 22, 2025·32 views
# CLAUDE.md - Next.js + TypeScript + Tailwind + shadcn + React Query

## Tech Stack

- **Language**: TypeScript (^5.0.0)
- **Framework**: Next.js (App Router)
- **Styling**: Tailwind CSS
- **Component Library**: shadcn/ui
- **Data Fetching**: React Query (TanStack Query)
- **Testing**: Jest + React Testing Library
- **Package Manager**: pnpm

## Development Commands

```bash
pnpm dev        # Start development server
pnpm build      # Production build
pnpm start      # Start production server
pnpm lint       # Run ESLint
pnpm format     # Run Prettier
pnpm test       # Run tests
pnpm test:watch # Run tests in watch mode
```

## Directory Structure

```
src/
├── app/              # Next.js App Router pages
│   ├── layout.tsx    # Root layout
│   ├── page.tsx      # Home page
│   └── api/          # API routes
├── components/       # React components
│   ├── ui/           # shadcn/ui components
│   └── [feature]/    # Feature-specific components
├── hooks/            # Custom React hooks
├── lib/              # Utility functions and configurations
│   ├── utils.ts      # General utilities
│   └── api.ts        # API client
├── types/            # TypeScript type definitions
└── tests/            # Test files
```

## React Query Implementation

### Query Client Setup

Initialize QueryClient in the root layout:

```typescript
// 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({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
        refetchOnWindowFocus: false,
      },
    },
  }))

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
```

### Query Key Patterns

Use domain-prefixed query keys:

```typescript
// hooks/use-users.ts
export const userKeys = {
  all: ['users'] as const,
  lists: () => [...userKeys.all, 'list'] as const,
  list: (filters: Filters) => [...userKeys.lists(), filters] as const,
  details: () => [...userKeys.all, 'detail'] as const,
  detail: (id: string) => [...userKeys.details(), id] as const,
}
```

### Query Hooks

```typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

// Fetch query
export function useUser(id: string) {
  return useQuery({
    queryKey: userKeys.detail(id),
    queryFn: () => fetchUser(id),
    enabled: !!id,
  })
}

// Mutation with cache invalidation
export function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: updateUser,
    onSuccess: (data) => {
      queryClient.invalidateQueries({ queryKey: userKeys.detail(data.id) })
      queryClient.invalidateQueries({ queryKey: userKeys.lists() })
    },
  })
}

// Infinite query for pagination
export function useInfiniteUsers(filters: Filters) {
  return useInfiniteQuery({
    queryKey: userKeys.list(filters),
    queryFn: ({ pageParam = 0 }) => fetchUsers({ ...filters, offset: pageParam }),
    getNextPageParam: (lastPage) => lastPage.nextOffset,
  })
}
```

## shadcn/ui Usage

Use shadcn/ui components by default for form elements, cards, dialogs, etc.

### Component Installation

```bash
pnpm dlx shadcn@latest add button
pnpm dlx shadcn@latest add card
pnpm dlx shadcn@latest add dialog
pnpm dlx shadcn@latest add form
```

### Component Usage

```typescript
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog'

export function UserCard({ user }: { user: User }) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>{user.name}</CardTitle>
      </CardHeader>
      <CardContent>
        <p className="text-muted-foreground">{user.email}</p>
        <Dialog>
          <DialogTrigger asChild>
            <Button variant="outline" size="sm">
              Edit
            </Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>Edit User</DialogTitle>
            </DialogHeader>
            {/* Form content */}
          </DialogContent>
        </Dialog>
      </CardContent>
    </Card>
  )
}
```

## Code Standards

### TypeScript

- Use explicit return types on functions
- Avoid `any` - prefer `unknown` with type guards
- Use interfaces for object shapes, types for unions/primitives
- Prefer `const` assertions for literal types

### React

- Use arrow functions for components
- Destructure props in function signature
- Co-locate component-specific hooks
- Use `'use client'` only when necessary

### Import Order

1. React and Next.js imports
2. Third-party library imports
3. Local component imports
4. Local utility imports
5. Type imports

```typescript
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { User } from '@/types'
```

### Styling

- Use Tailwind CSS utilities for styling
- Use `cn()` helper for conditional classes
- Follow mobile-first responsive design
- Use CSS variables for theming

```typescript
import { cn } from '@/lib/utils'

<div className={cn(
  'flex items-center gap-4 p-4',
  'rounded-lg border bg-card',
  isActive && 'border-primary',
  className
)}>
```

## Error Handling

```typescript
// API error handling
export async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`)

  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.statusText}`)
  }

  return response.json()
}

// Component error handling
export function UserProfile({ id }: { id: string }) {
  const { data, error, isLoading } = useUser(id)

  if (isLoading) return <Skeleton className="h-32 w-full" />
  if (error) return <ErrorMessage error={error} />
  if (!data) return null

  return <UserCard user={data} />
}
```

## Testing

```typescript
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  })
  return ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

describe('UserCard', () => {
  it('displays user information', async () => {
    render(<UserCard user={mockUser} />, { wrapper: createWrapper() })

    expect(screen.getByText(mockUser.name)).toBeInTheDocument()
    expect(screen.getByText(mockUser.email)).toBeInTheDocument()
  })

  it('opens edit dialog on button click', async () => {
    const user = userEvent.setup()
    render(<UserCard user={mockUser} />, { wrapper: createWrapper() })

    await user.click(screen.getByRole('button', { name: /edit/i }))

    await waitFor(() => {
      expect(screen.getByRole('dialog')).toBeInTheDocument()
    })
  })
})
```

Comments

No comments yet

Be the first to share your thoughts!