Coding Standard
How we build frontends at Vesperia — the conventions every React app follows, grounded in idxnet-web.
This is the Vesperia frontend standard. Our reference implementation is idxnet-web (React 18 + Vite + SWC, react-router v7, TanStack Query, Tailwind). New frontends follow these conventions, and existing ones converge on them. When in doubt, read how idxnet-web does it and match.
This page replaces the old frontend guide, which documented a stack we no longer build with (page.jsx/root.jsx, @imaginary-cloud eslint, resources/app/). None of that applies anymore.
The stack
- React 18 as a single-page app, bundled with Vite using the SWC plugin.
- TypeScript in strict mode — non-negotiable (see below).
- react-router v7 with
createBrowserRouterand a feature-module architecture (see Routing). - TanStack Query for all server/async state (see Data Fetching).
- Tailwind CSS for styling, composed with
cn()andtv()(tailwind-variants), plus Radix UI primitives insrc/components/ui/. - Zod for runtime validation, always via the shared helpers in
src/lib/zod.ts. - Vitest + Testing Library + jsdom for tests, colocated with the code.
TypeScript
Strict mode is on, and we keep the stricter flags enabled:
strictnoUnusedLocalsnoUncheckedIndexedAccess— indexing an array or record yieldsT | undefined; handle theundefined.verbatimModuleSyntax— import/export syntax is emitted verbatim, which is why thetypekeyword is required on type-only imports.
Avoid any. Prefer precise types, unknown with narrowing, and Zod-inferred types for anything crossing the network boundary.
React imports
Always import the namespace, never default or named hook imports:
import * as React from "react";
function Counter() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
// ...
}, []);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}Do not write import React from "react" or import { useState } from "react". Use React.useState, React.useEffect, React.useMemo, and so on.
Imports and ordering
The type keyword is required for type-only imports (consistent-type-imports is an error, enforced by ESLint):
import type { RouteObject } from "react-router";
import type { ColumnDef } from "@tanstack/react-table";
import { protectedHttpClient } from "@/lib/http-client";Import groups are ordered and auto-sorted by Prettier:
react- third-party packages
@/path-alias imports- relative imports
The path alias @/ maps to src/. Prefer it over long relative chains.
File naming
All files use kebab-case. No camelCase, PascalCase, or snake_case filenames — even for files that export a React component.
user-log.routes.ts
user-log.api.ts
user-log.schemas.ts
crud-table.tsx
holiday-schedule-form.tsxFeature modules
A feature lives under src/app/<domain>/ and owns its slice end-to-end. The conventional files are:
<domain>.routes.ts— exports a*Routes()function consumed bysrc/router.ts<domain>.api.ts— TanStack Query options + mutations (the data layer)<domain>.schemas.ts— Zod schemas for the domain<domain>.types.ts— types (often inferred from the schemas)<domain>.config.ts— route paths, query-key factory, options, copycomponents/,pages/,contexts/— the module's UI and local state
Shared code lives outside feature modules: src/components/ (UI primitives in ui/, shells in layout/), src/hooks/, src/lib/, src/config/.
Styling
Compose Tailwind classes with cn() (from src/lib/styling.ts) and build variants with tv() (tailwind-variants):
import { cn } from "@/lib/styling";
<div className={cn("rounded-lg border p-4", isActive && "border-primary")} />;Colors use CSS custom properties via HSL. Reach for the Radix-based primitives in src/components/ui/ before hand-rolling interactive elements.
Zod
Always use the shared helpers from src/lib/zod.ts rather than re-deriving validation inline — they carry our standard messages and null-handling:
import { z } from "zod";
import { nullableStringSchema, requiredDateSchema, requiredUuidSchema } from "@/lib/zod";
const holidayScheduleSchema = z.object({
id: requiredUuidSchema,
date: requiredDateSchema,
notes: nullableStringSchema,
});Check existing *.schemas.ts files for patterns before adding a new one. Derive form schemas from the entity schema with .omit()/.pick() instead of duplicating fields.
Tables
We use TanStack Table. For columns whose value is formatted text, use accessorFn (not a custom JSX cell):
import type { ColumnDef } from "@tanstack/react-table";
const columns: ColumnDef<HolidaySchedule>[] = [
{ accessorFn: (row) => formatDate(row.date), header: "Date" },
];Reserve custom cell renderers for genuinely interactive or non-text content.
Reuse first
Before creating a new utility, hook, or component, check what already exists:
src/hooks/src/components/(andsrc/components/ui/)src/lib/(includingsrc/lib/utils.ts)
Duplicating a helper that already exists is a review comment waiting to happen.
Formatting
Prettier owns formatting — don't fight it:
- double quotes
- 2-space indentation
- semicolons
- LF line endings
Testing
Vitest + Testing Library + jsdom. Test files are colocated next to the code as *.test.ts(x):
npx vitest run # all tests
npx vitest run src/lib/format-date.test.ts # a single fileCommits and PRs
- Use Conventional Commits (
feat:,fix:,chore:, …). Prefer small, modular commits scoped to one logical change over bulk commits. - A PR description includes: (1) the problem statement, (2) the solution description, (3) the list of affected files/areas.
Commands
npm run build # tsc + vite build
npm run lint # eslint + tsc --noEmit
npx vitest run # testsDon't run npm run dev against a shared environment where a dev server is already running externally — check first.