Next.js frontend
Applies to: the new frontend under the /next-frontend folder.
We’re using App Router with React Compiler enabled. It means that files in the folder imply the routes, and within each file for a page you don’t have to worry about useMemo or useCallback at all.
1 Server components by default
If a page or component can be an async server component, it must be. Fetching the BetterAuth session, computing derived values, and awaiting API calls all work server-side.
When one interactive widget forces client rendering (a dropdown, a toggle), extract that widget into its own "use client" component and leave the rest of the tree server-rendered. Don’t mark a whole page as client because of a single control button.
Where you must use "use client" for a non-obvious reason (e.g. passing icon functions from server to client throws a hard error under Next 16), say so in a comment.
2 Types come from the schema
Never hand-declare a type that describes API data. Derive it:
type StatColumns = Pick<components["schemas"]["LiveResult"], "best" | "average" | "global_pos">;
Use Pick, Partial, and Omit on the generated OpenAPI types so a spec refactor breaks the build instead of silently drifting. The same applies to Payload types — regenerate them (yarn types:payload) rather than editing src/types/payload.ts.
- No
ascasts. If you need one, the type is wrong somewhere — say what you tried in the PR. - No
!non-null assertions. Restructure so the value can’t be undefined (hoist the lookup above an early return; React Compiler means you no longer needuseCallback, so the “hooks before early return” constraint is looser than you think). - Prefer
undefinedovernullfor optional values, and letuseState<number>()infer. - Use library-provided types (
TFunctionfrom i18next, Next’sinstrumentationtypes) instead of writing your own structural equivalents. Spend five minutes in the library’s source before you spend ten minutes to painstakingly mimic a 20-line interface. - Type a wrapper from the component it wraps: intersect your own props with
ComponentPropsWithoutRef<typeof Icon>so the wrapper accepts everything the wrapped component does.- Anything returned by Chakra’s
createIconessentially is anIconcomponent and does not need to be wrapped within<Icon>{...}</Icon>again.
- Anything returned by Chakra’s
- Pass real types across props. A boolean prop receives
true, not"true". - Use
as constfor lookup objects that should narrow.
3 Data fetching with Tanstack Query
- We rely on
openapi-typescriptas a bridge between the OpenAPI types and Tanstack Query. Pullapi.queryOptions(...)once and spread it, rather than nestinguseQuerycalls —api.useQueryis itself a thin wrapper arounduseQuery. - Don’t use
enabled: falseto freeze a query into a dumb prop container. If you have the data already, passinitialData.- Reshape with
selectif the shape doesn’t match.
- Reshape with
- Let the query client hold the state.
queryClient.setQueryData(key, (old) => next(old))supports updater functions exactly likeuseState— use it instead of mirroring server state into an actual ReactuseStatewhile manually syncing the two. setQueryDataon a key that doesn’t exist is a no-op, so you rarely need to check for presence first.isPendingis only true for the initial fetch. UseisFetchingif you care about subsequent refetches and invalidations.- If you just refetched, don’t also patch the cache by hand — the refetch already brought the truth.
- Prefer
setQueryDatafrom a mutation’s response overinvalidateQuerieswhen the server already told you the new state (it usually does). This saves a network round trip. - Don’t fetch data incidentally deep in a helper “because it’s cached anyway”. Fetch it explicitly where it’s needed, so the next developer can find and reuse it.
- Keep
lazyMounton dialogs, tabs, and accordions. Without it every panel’s queries fire on page load — including the “list every Delegate region in the database” request behind a tab the user never opened. - Give toasts stable IDs to prevent duplicates across re-renders.
4 Component structure
- Nothing complex inside JSX. Inline arrow callbacks are fine only for trivial one-liners (
onClick={() => setOpen(true)}) and boolean comparisons. Anything with anif, amapthat reshapes data, or more than one statement moves to a named const in the component preamble. - Nothing complex inside
if (...)either. Assign the call’s result to a named variable and test the variable — the name is necessary to tell the next reader what the condition means. - Extract a dialog or a repeated block into its own component file once it’s more than incidental.
- Prefer
childrenover atext?: string | ReactNodeprop withtypeofchecks.childrenhandles strings natively.- You can even consider child callbacks: React resolves everything passed in between the opening and closing tags as
childrenand allows pretty much any type. So you can do something like:<WrapperRow>{(wrappedItem) => whatToDoWithIt(wrappedItem)}</WrapperRow>
- You can even consider child callbacks: React resolves everything passed in between the opening and closing tags as
- Put context providers as high in the tree as they can reasonably go. A provider that toasts should render its own
Toasterrather than requiring consumers to remember one. - Be consistent about how a context is consumed: either every child reads it internally, or every child receives props. Don’t mix within one feature.
useEffectis for synchronizing with systems outside React (a websocket, a timer) and must return a cleanup function. Anything else needs justification.- Read https://react.dev/learn/you-might-not-need-an-effect if you want to learn more. We are really, really allergic to
useEffectand every existing one is the conclusion of a very lengthy debate. useLayoutEffectneeds an even better justification — there’s essentially one in the entire codebase.
- Read https://react.dev/learn/you-might-not-need-an-effect if you want to learn more. We are really, really allergic to
- Wrap
useEffectEventinside the hook that owns the effect. Per the React docs, Effect Events must never be passed to other components or hooks. - Everything user-visible goes through i18n. Server components use
const { t } = await getT();.- You will see lots of hard-coded English strings in the existing code. They come from Alpha builds or even earlier proof-of-concept designs. Feel free to properly localize them if you have a moment.
5 Other important conventions
- Use Luxon
DateTime, not JSDate. Reconsider once wide-spreadTemporalsupport has landed; see the MSN docs for updates. - Parse dates before sorting them. Never sort date strings with
localeCompare. - Icons come from our own pack or Lucide. Don’t introduce a new react-icons family.