SE Hub
Front End Development

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 createBrowserRouter and a feature-module architecture (see Routing).
  • TanStack Query for all server/async state (see Data Fetching).
  • Tailwind CSS for styling, composed with cn() and tv() (tailwind-variants), plus Radix UI primitives in src/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:

  • strict
  • noUnusedLocals
  • noUncheckedIndexedAccess — indexing an array or record yields T | undefined; handle the undefined.
  • verbatimModuleSyntax — import/export syntax is emitted verbatim, which is why the type keyword 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:

  1. react
  2. third-party packages
  3. @/ path-alias imports
  4. 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.tsx

Feature 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 by src/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, copy
  • components/, 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/ (and src/components/ui/)
  • src/lib/ (including src/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 file

Commits 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  # tests

Don't run npm run dev against a shared environment where a dev server is already running externally — check first.

On this page