| name | vinext-frontend |
| description | Frontend architecture reference for React 19 + Vinext (Vite App Router) + TanStack Query + Zodios + shadcn/ui + react-hook-form projects running on Cloudflare Workers. Load when writing React components, forms, API clients, or data fetching code in this stack. |
Vinext Frontend Stack
Patterns extracted from a production React 19 + Vinext + Cloudflare Workers codebase. Copy the patterns, not the app-specific code.
Stack
| Layer | Choice |
|---|
| Build / router | Vinext on Vite 8 โ Next.js-compatible App Router |
| UI framework | React 19 (RSC via Vinext) |
| Styling | Tailwind CSS v4 (@tailwindcss/vite) + shadcn/ui (New York style) |
| Data fetching | TanStack Query v5 |
| HTTP client | Zodios with shared Zod schemas (single source of truth) |
| Forms | react-hook-form + @hookform/resolvers/zod |
| Global state | Jotai (only for cross-page UI state) |
| Animation | motion/react-m with LazyMotion + domAnimation |
| Icons | lucide-react |
| Toasts | sonner |
| Package manager | Bun only โ never npm/yarn/pnpm |
| Linter / formatter | Biome (no ESLint, no Prettier) |
Directory layout
src/
โโ app/ # Vinext App Router
โ โโ layout.tsx # Root โ wraps with <Providers>
โ โโ page.tsx # Home route
โ โโ <feature>/page.tsx
โ โโ api/<feature>/route.ts # API handlers (server-side)
โโ components/
โ โโ ui/ # shadcn primitives โ NEVER edit directly
โ โโ providers.tsx # All client providers in one tree
โ โโ <feature>-form.tsx # kebab-case feature components
โโ lib/
โ โโ api.ts # Zodios client singleton
โ โโ api-contract.ts # makeApi([...]) โ THE contract
โ โโ schema.ts # Zod input (form) schemas
โ โโ atoms.ts # Jotai atoms
โ โโ utils.ts # cn() helper
โ โโ use-*.ts # Custom hooks
โโ index.css # Tailwind + global styles
schemas/
โโ *.dto.ts # Zod output (response) schemas โ PascalCase
Path alias: @/* โ ./src/*. ../../schemas/* for response DTOs kept outside src/.
Core pattern: Zodios as the API contract
The single highest-value pattern in this stack. One file defines all endpoints, types flow automatically to both client callsites and server handlers.
import { makeApi } from '@zodios/core'
import { z } from 'zod'
import { FoodRowSchema } from '../../schemas/Food.dto'
import { foodSchema } from './schema'
export const apiDefinition = makeApi([
{
method: 'get',
path: '/foods',
alias: 'listFoods',
response: z.array(FoodRowSchema),
parameters: [
{ name: 'q', type: 'Query', schema: z.string().optional() },
{ name: 'limit', type: 'Query', schema: z.number().optional() }
]
},
{
method: 'post',
path: '/foods',
alias: 'createFood',
response: FoodRowSchema,
parameters: [{ name: 'body', type: 'Body', schema: foodSchema }]
}
])
import { Zodios, type ZodiosPlugin } from '@zodios/core'
import { AxiosError } from 'axios'
import { toast } from 'sonner'
import { apiDefinition } from './api-contract'
const errorToastPlugin: ZodiosPlugin = {
name: 'error-toast',
error: async (_api, _config, error) => {
if (error instanceof AxiosError) {
const status = error.response?.status
const data = error.response?.data as { error?: unknown } | undefined
const serverMessage = typeof data?.error === 'string' ? data.error : undefined
toast.error(serverMessage ?? `Request failed (${status})`)
} else {
toast.error(error.message || 'Request failed')
}
throw error
}
}
export const api = new Zodios('/api', apiDefinition)
api.use(errorToastPlugin)
Usage from components โ fully typed via the alias:
api.listFoods({ queries: { q: 'chicken', limit: 10 } })
api.createFood({ name: 'rice', calories: 250 })
Two schema locations โ intentional split
src/lib/schema.ts โ input schemas (form data). Loose: z.number().min(0).default(0). Shared between RHF resolver and server parse.
schemas/*.dto.ts โ output schemas (server responses). Strict. Kept outside src/ to stay portable and avoid app imports. Naming: PascalCase.dto.ts, <Name>RowSchema + type <Name>Row = z.infer<typeof ...>.
Never write a ZodiosPlugin as an axios interceptor
Use the Zodios plugin API (error, request, response hooks) instead of wrapping a custom axios.create() instance. Keeps error handling scoped to the client.
TanStack Query patterns
const [queryClient] = useState(
() => new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } }
})
)
Forms โ react-hook-form + Zod
Every form follows this exact pattern. No ad-hoc useState + fetch.
const form = useForm<FoodInput>({
resolver: zodResolver(foodSchema) as never,
mode: 'onBlur',
defaultValues: {
name: '',
calories: undefined as unknown as number,
protein: undefined as unknown as number,
serving: '1 serving'
}
})
Use shadcn/ui <Form>, <FormField>, <FormItem>, <FormLabel>, <FormControl>, <FormMessage> โ they wire up aria-describedby, error state, and labels automatically.
Number input gotcha (CRITICAL โ bites every new dev)
<Input
type='number'
{...field}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : 0)}
/>
<Input
type='number'
{...field}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value ? Number(e.target.value) : undefined)}
/>
Why:
value={field.value ?? ''} โ shows empty string when undefined. Without it React warns controlledโuncontrolled.
- onChange maps empty โ
undefined, never back to 0. Otherwise the default 0 resurrects on every keystroke and the field is impossible to clear.
defaultValues should be undefined for number fields. Use z.number().default(0) in the schema to backfill on submit.
Always mode: 'onBlur'
Validate when focus leaves a field, not only on submit. Gives immediate feedback without being noisy on every keystroke.
Styling conventions
cn() utility
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Animation with motion
import * as m from 'motion/react-m'
<m.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 24 }}
/>
SSR hydration gotcha
initial={{ opacity: 0 }} leaves elements invisible during SSR until hydration finishes.
src/index.css:
html:not([data-hydrated]) [data-motion-initial] { opacity: 1 !important; }
- In
Providers:
useEffect(() => { document.documentElement.dataset.hydrated = '' }, [])
- Provide a
useSkipAnimation() hook returning true on first client render so you can write initial={skipAnimation ? false : {...}}.
Providers ordering (matters)
<AuthProvider> {}
<LazyMotion features={domAnimation} strict>
<JotaiProvider> {/* Inside auth so atoms reset on logout */}
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</JotaiProvider>
</LazyMotion>
</AuthProvider>
Global state guidance
- Server state โ TanStack Query. Never put API responses in Jotai or Context.
- Cross-page UI state โ Jotai. E.g. selected date, open sheets that survive navigation.
- Local-only state โ
useState. Don't reach for a global store just to share between siblings.
TypeScript conventions
Biome config (copy verbatim)
{
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 120
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "single",
"semicolons": "asNeeded",
"trailingCommas": "none"
}
},
"linter": { "enabled": true, "rules": { "recommended": true } }
}
Always run bunx biome check --write after edits.
Bootstrap checklist when adopting this stack
bun install with the dependencies listed above.
- Copy
biome.json, tsconfig.json, vite.config.ts.
- Create
src/lib/utils.ts (cn()), src/lib/api-contract.ts, src/lib/api.ts, src/lib/schema.ts.
- Create
schemas/ directory outside src/ for response DTOs.
- Add shadcn primitives with
bunx --bun shadcn@latest init then add <component>.
- Mirror
src/components/providers.tsx ordering.
- Use one form (e.g.
food-form.tsx) as the template for all other forms.
- Add
mode: 'onBlur' and the number-input gotcha fix to every useForm call from day one.
Anti-patterns to avoid
- โ Hand-written fetch wrappers when Zodios can generate them.
- โ Editing files in
src/components/ui/.
- โ Template-literal conditional class strings.
- โ
onChange that maps empty string to 0 on number inputs.
- โ Storing server data in Jotai or React Context.
- โ
mode: 'onSubmit' (the default) โ use 'onBlur'.
- โ Mixing
npm/yarn with Bun.