Imported from ForestLee0513/iinfo-dx-admin (
AGENTS.md). Install upstream withnpx skills add ForestLee0513/iinfo-dx-admin. Copyright stays with the author.
This is React Router (framework mode), not Next.js
This project runs on React Router v8 in framework mode (SSR-enabled) — it is not Next.js. There is no App Router, no page.tsx/layout.tsx special files, no server actions, and searchParams is a plain client-side hook, not an awaited Promise. Routes are declared explicitly in app/routes.ts and rendered by files under app/routes/. Read node_modules/react-router/dist/development/*.d.ts or https://reactrouter.com/ (framework mode docs) before assuming Next.js conventions apply.
UI copy and code comments are written in Korean. Path aliases: @/* → repo root, ~/* → app/ (both declared in tsconfig.json / vite.config.ts; prefer @/* for anything outside app/, since that's what's used throughout the codebase today).
Commands
Package manager is yarn (yarn.lock present).
yarn dev— dev server (React Router dev, HMR) at http://localhost:5173yarn build— production build (react-router build) →build/client+build/serveryarn start— serve the production build (react-router-serve ./build/server/index.js)yarn typecheck— regenerates React Router's route types (react-router typegen) then runstsc
There is no lint script and no ESLint config in this project (only @tanstack/eslint-plugin-query sits in devDependencies, unused by any script) — don't assume yarn lint works, and don't invoke eslint directly on the assumption a flat config exists. There is also no test setup.
Environment
There is no .env.local convention here (no NEXT_PUBLIC_* vars). The only environment knob is:
VITE_API_URL— backend base URL used by the browser axios client and by the dev server Vite proxy. Backend routes live under/api/v1/web/...and/api/v1/admin/....
In both dev and prod, the browser calls the backend configured by VITE_API_URL; production uses https://iinfo-dx-api.forestlee.me. There is no basePath/assetPrefix concept in this setup, so there's no static-import requirement for images — reference public/ assets by plain string path as usual.
Project layout
Styling is Tailwind CSS v4 (Vite plugin, no tailwind.config, no separate styles/ folder — see below). Each top-level folder owns exactly one concern; keep the boundaries from overlapping.
📦 repo root
┣ 📂api # per-domain requests + server-state (TanStack Query)
┣ 📂app # routing (React Router framework mode) and screen assembly
┣ 📂components # feature-scoped, reusable UI
┣ 📂lib # shared client foundations (HTTP + cache clients)
┣ 📂providers # React providers wiring lib clients into the tree
┗ 📂public # static assets (fonts, favicon) served as-is
The sections below give the rules for each folder, in that order.
api/<domain>/ — requests & server-state
Each backend domain gets its own folder (see api/auth/ and api/users/ as reference implementations):
📂api/<domain>
┣ 📜constants.ts # route base (e.g. USERS_BASE = "/api/v1/admin/users") + enum-like consts
┣ 📜types.ts # request/response interfaces, one block per endpoint (path in a comment)
┗ 📜requests.ts # request fns + TanStack layer together: <domain>Keys factory, queryOptions, hooks
Unlike a strict request/query split, requests.ts holds both the raw async request function and its paired TanStack hook right next to each other (e.g. getUserDetail immediately followed by userDetailQueryOptions and useUserDetailQuery). Components only ever import the hooks (useUserDetailQuery, useBanUserMutation, …) from requests.ts; the plain async functions (startOAuthLogin, refreshSession, …) are imported directly only for non-hook flows that run outside a React hook (interceptors, window.location redirects).
A domain can add extra files beyond these three when it has domain logic that isn't a request or a type — e.g. api/auth/roles.ts holds canAccessAdmin / isRole, pure helpers derived from AUTH_MEMBER_ROLE, kept out of requests.ts because they aren't network calls.
Query keys — the <domain>Keys factory
The client cache is TanStack Query — treat it as server-state, not a client store. State is partitioned per domain by the top-level string of each query key:
export const authKeys = {
all: ["auth"] as const, // namespace root for the whole domain
me: () => [...authKeys.all, "me"] as const,
};
- Namespacing rule: every domain's
allMUST start with a unique string (["auth"],["users"], …). Never hand-build a key in a component — always go through the factory so the prefix stays consistent. - Domains don't clobber each other: invalidate/remove match by array prefix, so
removeQueries({ queryKey: authKeys.all })only touches keys starting with["auth", …]. Auth cache is wiped only by a same-prefix collision,queryClient.clear(), or a key-lessinvalidateQueries()— all deliberate. - Derive child keys from the parent (
[...userKeys.all, "detail"]→[...userKeys.details(), userId]) so a broader removal (e.g.userKeys.all) cleans the entire domain in one call. - Cache writes on mutation success go through named helpers in
requests.ts(e.g.seedMyInfo,invalidateUser), not scatteredsetQueryData/invalidateQueriescalls in components. Login mutations seed themecache from the login response to avoid an immediate follow-up/merequest.
app/ — routing & assembly only
app/routes.ts is the single source of truth for the route tree, built with the @react-router/dev/routes helpers (index, route, layout):
export default [
index("routes/login.tsx"),
layout("routes/admin.tsx", [
route("members", "routes/admin.members.tsx"),
route("members/:userId", "routes/admin.members.$userId.tsx"),
route("permissions", "routes/admin.permissions.tsx"),
]),
] satisfies RouteConfig;
Route filenames (e.g. admin.members.$userId.tsx) are just conventional labels registered in routes.ts — React Router's framework mode does not infer routes from the filesystem the way Next.js's App Router does, so the URL structure lives in routes.ts, not in folder nesting.
- Route files stay thin where possible, but unlike Next.js's page/layout split, a React Router layout route (
admin.tsx) commonly owns real client logic (auth gate, nav, session-derived redirects viauseEffect+useNavigate) because there's no server-side middleware step to do it instead — seeapp/routes/admin.tsx. - Typed route args come from the generated
./+types/<route-file>module (e.g.import type { Route } from "./+types/login"), regenerated byyarn typecheck(react-router typegen). UseRoute.MetaArgsformeta(),Route.ErrorBoundaryPropsforErrorBoundary, etc. meta()is a plain exported function returning an array of tag descriptors ({ title },{ name, content }) — this replaces Next.js'smetadataexport/generateMetadata.searchParamsis a client hook, not aPromise. Read query params withuseSearchParams()fromreact-routerinside the component (seeapp/routes/login.tsx'soauthErrorhandling) — there is noawait searchParamsstep.- Root layout (
app/root.tsx) exportsLayout(the<html>/<head>/<body>shell,<Meta>/<Links>/<Scripts>/<ScrollRestoration>), a defaultAppcomponent (global providers +<Outlet />), andErrorBoundary(checked withisRouteErrorResponsefor 404s vs. unexpected errors) — this trio is React Router's root-level equivalent of Next.js's root layout +not-found/errorfiles, but all three live in one file. - Nested layouts are declared via
layout(...)inroutes.ts, not via a parenthesized route-group folder —app/routes/admin.tsxwraps every route nested under it inroutes.tsand renders them through its own<Outlet />.
components/ — feature-scoped UI
One folder per feature component, one component per file, split by concern. A simple component starts flat (e.g. today's components/ui/Badge.tsx, components/table/DataTable.tsx); when it grows enough to need local state, context, or multiple sub-parts, promote it to a folder:
📂components/<Name>
┣ 📜index.tsx # the component itself; module parts attach here (Foo.Body = Body) just before export
┣ 📂parts # helper components too small to live globally, one file each
┣ 📜types.ts # component-local types (props, context types) — reads as the component's table of contents
┗ 📜utils.ts # component-local helpers (optional; omit if unused)
For components that own local state, add two more folders:
┣ 📂contexts # Context used only inside this component
┗ 📂hooks # hooks used only inside this component
- Scope decides placement. Shared across screens → global (repo-level
hooks/,contexts/, or a globalcomponents/); used only inside one component → that component's localparts/hooks/contexts/. Same rule for every subfolder. index.tsxis the container. For stateful components it owns state + providers and delegates rendering toparts/;parts/consumes state (via Context/hooks) and holds none of its own — keep the "owns state (index) ↔ consumes state (parts)" boundary clean.types.tsis the component's index — reading it alone should reveal what the component takes and exposes, without opening the implementation.
lib/ — shared client foundations
App-wide plumbing that isn't tied to any domain or screen. Domains grow, screens grow; these files stay reused.
📂lib
┣ 📜axios.ts # shared HTTP instance + 401 auto-recovery
┣ 📜query-client.ts # server/browser QueryClient factory
┗ 📜api-error.ts # shared error-message extraction for API errors
axios.ts — shared HTTP instance & 401 recovery
- Exports the shared
apiaxios instance. Sessions are cookie-based (withCredentials: true), andAPI_BASE_URLcomes fromVITE_API_URL(see Environment above). - The access token for Bearer-protected endpoints lives in memory only (
setAccessToken), so a page refresh drops it intentionally — real identity rests in the httpOnly refresh cookie (XSS can't read it), and the in-memory token is a recoverable derivative. - On any 401 (except login/refresh requests themselves), the response interceptor calls
POST /refresh— deduplicated through a single shared promise so concurrent 401s trigger one refresh — then retries the original request exactly once (_retriedflag). A 401 on refresh/login itself is a credential error and propagates as-is (no recursion). A 401 that survives the retry calls theauthErrorCallbackregistered viasetAuthErrorCallback(seeapp/routes/admin.tsx, which wires it to clear themecache and redirect to/).
query-client.ts — server/browser factory
retryskips all 4xx errors (retry only5xx, up to 3×): a 401 reaching TanStack Query means the axios interceptor's refresh-and-retry already failed, so retrying again is pointless. The two layers manage retries without overlapping.getQueryClient()returns a newQueryClientper server request (no cross-user cache leaks, matters because React Router framework mode renders on the server) and a browser singleton (survives suspend/re-render without discarding the hydrated cache). Callers just callgetQueryClient()— the server/browser rule stays sealed in this file.
providers/ — wiring lib clients into the tree
Providers connect the clients created in lib/ to the React tree. Today this is a single file:
📂providers
┗ 📜QueryProvider.tsx # inject QueryClient into the tree + mount Devtools
QueryProvider.tsx(thin) — callsgetQueryClient(), wrapschildreninQueryClientProvider, and mountsReactQueryDevtools. Nevernew QueryClient()here.- There is no dedicated session-bootstrap provider. Auth state is just the
mequery (useMyInfoQueryfromapi/auth/requests.ts): the admin layout route (app/routes/admin.tsx) reads it directly, shows a skeleton whileisLoading, and redirects out viauseNavigatewhen the query errors or the role failscanAccessAdmin/route-level role checks. Recovery from a dropped in-memory access token happens implicitly throughlib/axios.ts's 401 → refresh → retry flow, not through an explicit "restore session" step on mount.
OAuth login is a full-page redirect (window.location.assign), not XHR — the session cookie is set on the provider callback.
Styling & fonts
There is no separate styles/ folder — styling lives directly under app/:
📂app
┗ 📜app.css # Tailwind v4 entry + font-face + design tokens
app.cssis the Tailwind v4 entry point:@import "tailwindcss"replaces the old@tailwind base/components/utilities, and the@themeblock replacestailwind.config'stheme.extend. Pretendard / Pretendard JP are wired via plain@font-facerules pointing atpublic/fonts/*.woff2(notnext/font/local), split byunicode-rangeso Korean/Latin and Japanese glyphs load the matching variable-weight file under onefont-family: "Pretendard". The@themeblock promotes that into--font-sans, so components just use thefont-sansTailwind utility.- The admin console is locked to light theme (
color-scheme: lightonhtml, bodyinapp.css) so native controls (inputs, selects, scrollbars) don't pick up the system dark mode.