| name | orval-tanstack-query |
| description | Orval code generation + TanStack Query patterns for the @repo/api-client package. Use when regenerating TypeScript hooks from openapi.json, implementing the custom fetcher, or consuming generated hooks in apps/web. Triggers on tasks involving orval.config.ts, api-client generation, customFetcher, TanStack Query hooks from the Laravel API, or @repo/api-client imports. |
| frameworks | ["orval","tanstack-query","nextjs"] |
| languages | ["typescript"] |
| category | api |
| updated | "2026-04-29T00:00:00.000Z" |
Orval + TanStack Query Skill
Quick Reference
When to Use: Regenerating TypeScript hooks from openapi.json, implementing the fetcher, or consuming @repo/api-client hooks
Source of truth: apps/laravel/openapi.json → Orval → packages/api-client/src/generated/
Regenerate command: pnpm -F @repo/api-client generate
Package Structure
packages/api-client/
├── orval.config.ts # Orval config (input: openapi.json, output: generated/)
├── src/
│ ├── index.ts # Re-exports generated code + mutator types
│ ├── mutator/
│ │ └── custom-fetcher.ts # Injectable fetcher contract
│ └── generated/ # AUTO-GENERATED — do not edit manually
│ ├── api.ts # All hooks, query keys, mutation functions
│ └── *.ts # Per-endpoint type schemas
└── package.json
Orval Config
import { defineConfig } from 'orval'
export default defineConfig({
laravelApi: {
input: {
target: '../../apps/laravel/openapi.json',
},
output: {
mode: 'split',
target: './src/generated/api.ts',
schemas: './src/generated',
client: 'react-query',
clean: true,
prettier: true,
override: {
mutator: {
path: './src/mutator/custom-fetcher.ts',
name: 'customFetcher',
},
query: {
useQuery: true,
useMutation: true,
signal: true,
},
},
},
},
})
Custom Fetcher Contract
The customFetcher is the only HTTP implementation you need to provide. It's injected once at app startup.
export type CustomFetcher = <T>(url: string, init: RequestInit) => Promise<T>
let _fetcher: CustomFetcher | undefined
export function configureFetcher(fetcher: CustomFetcher): void {
_fetcher = fetcher
}
export const customFetcher = <T>(config: OrvalConfig): Promise<T> => {
if (!_fetcher) {
throw new Error('[@repo/api-client] No fetcher configured. Call configureFetcher() before making API requests.')
}
const { url, method, params, headers: configHeaders, data, signal } = config
const fullUrl = params ? `${url}?${new URLSearchParams(...)}` : url
return _fetcher<T>(fullUrl, {
method: method.toUpperCase(),
headers: { Accept: 'application/json', ...configHeaders },
signal,
...(data !== undefined ? { body: JSON.stringify(data) } : {}),
})
}
Web App Fetcher Implementation (Sanctum)
import type { CustomFetcher } from "@repo/api-client"
export const sanctumBrowserFetcher: CustomFetcher = async <T>(
url: string,
init: RequestInit
): Promise<T> => {
const origin = new URL(env.NEXT_PUBLIC_API_BASE_URL).origin
const method = ((init.method as string) || "GET").toUpperCase()
if (method !== "GET" && method !== "HEAD") {
let token = readXsrfToken()
if (!token) {
await fetch(`${origin}/sanctum/csrf-cookie`, { credentials: "include" })
token = readXsrfToken()
}
if (token) {
const headers = new Headers(init.headers as HeadersInit)
headers.set("X-XSRF-TOKEN", token)
init = { ...init, headers }
}
}
const response = await fetch(`${origin}${url}`, {
...init,
credentials: "include",
})
return response.json() as Promise<T>
}
Wire it up once at app startup:
import { configureFetcher } from "@repo/api-client"
import { sanctumBrowserFetcher } from "@/services/sanctum/browser-fetcher"
configureFetcher(sanctumBrowserFetcher)
Using Generated Hooks
import {
useGetApiV1Todos,
usePostApiV1Todos,
usePatchApiV1TodosId,
useDeleteApiV1TodosId,
} from "@repo/api-client"
export function useTodosQuery() {
return useGetApiV1Todos()
}
export function useCreateTodoMutation() {
const queryClient = useQueryClient()
return usePostApiV1Todos({
mutation: {
onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetApiV1TodosQueryKey() }),
},
})
}
export function useUpdateTodoMutation(id: number) {
const queryClient = useQueryClient()
return usePatchApiV1TodosId(id, {
mutation: {
onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetApiV1TodosQueryKey() }),
},
})
}
export function useDeleteTodoMutation() {
const queryClient = useQueryClient()
return useDeleteApiV1TodosId({
mutation: {
onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetApiV1TodosQueryKey() }),
},
})
}
Generated Hook Naming Convention
Orval generates hook names from the HTTP method + path:
| Endpoint | Generated Hook |
|---|
GET /api/v1/todos | useGetApiV1Todos |
POST /api/v1/todos | usePostApiV1Todos |
GET /api/v1/todos/{id} | useGetApiV1TodosId |
PATCH /api/v1/todos/{id} | usePatchApiV1TodosId |
DELETE /api/v1/todos/{id} | useDeleteApiV1TodosId |
POST /api/v1/auth/mobile/login | usePostApiV1AuthMobileLogin |
Query keys follow the same pattern with a get prefix:
getGetApiV1TodosQueryKey() — for useGetApiV1Todos
Regeneration Workflow
pnpm -F @repo/laravel openapi:generate
pnpm -F @repo/api-client generate
pnpm -F @repo/api-client build
pnpm build --filter=@repo/api-client
Package Exports
export * from './generated/api'
export type { CustomFetcher } from './mutator/custom-fetcher'
export { configureFetcher } from './mutator/custom-fetcher'
Adding a New API Endpoint Checklist
- Implement Laravel controller + route
- Add Scribe annotations (
@group, @bodyParam, @response)
- Run
pnpm -F @repo/laravel openapi:generate (commits openapi.json)
- Run
pnpm -F @repo/api-client generate (regenerates src/generated/)
- Import and use the new
useXxx hook in apps/web/features/[feature]/api/
- Commit all three: PHP code +
openapi.json + generated TypeScript