Frontend
The frontend stack, the reasoning behind it, and the patterns for everyday work.
The frontend stack is Next.js (App Router), React, and TypeScript. Styling is Tailwind v4 with
shadcn/ui components, themed by @plainconceptsplatform/ui-theme. Icons come from Lucide.
Dependency injection uses inversify-hooks.
The idea behind the foundation: share the look and the conventions, not a component library. Every app installs the same token package and follows the same structure, so a developer moving between apps already knows where things live. Apps still own their own components and features.
| Concern | Choice |
|---|---|
| Language | TypeScript (strict) |
| Framework | Next.js (App Router) + React |
| Components | shadcn/ui on Radix |
| Styling | Tailwind CSS v4 |
| Theme | @plainconceptsplatform/ui-theme |
| Icons | Lucide (lucide-react) |
| Dependency injection | inversify-hooks |
| Internationalization | react-i18next + i18next |
| Data fetching | TanStack Query (or RSC / Server Actions) |
| Forms | react-hook-form + zod |
| Lint and format | Biome |
| Tests | Vitest + Playwright |
Architecture
Apps use a pragmatic Feature-Sliced Design on the App Router:
src/
app/ Next.js routing shell (layouts, segments, route handlers) + providers
views/ the screen for a route, composed from widgets and features
widgets/ self-contained UI blocks (earn it)
features/ user interactions and use-cases (earn it)
entities/ business models and their api/ui (earn it)
shared/ ui (shadcn), lib (cn, formatters), api clients, hooksTwo rules carry most of the value. First, dependencies point one way: a layer may import from
layers below it, never above, and siblings do not import each other. A feature can use an
entity and shared, but an entity never reaches up into a feature. This is what keeps slices
easy to move or delete. Second, each slice has one public entry (index.ts); import a slice
through that, never by reaching into its files.
Start a new app with only app, views, and shared. Add the other layers when a real need
appears, not before. The views name is deliberate: it is the Feature-Sliced "pages" layer,
renamed so it does not collide with the Next.js App Router app directory. Full layer rules are in
Architecture.
Theming
Tokens flow through three layers. Primitive values (the raw brand palette) feed semantic tokens
(--primary, --background, --border, --radius), which are exposed as Tailwind utilities. You
work with the semantic layer:
// Good: semantic tokens adapt to light/dark and to theme updates
<div className="rounded-lg border bg-card p-4 text-card-foreground">
<button className="bg-primary text-primary-foreground">Save</button>
</div>
// Avoid: hardcoded values drift from the theme and break dark mode
<div style={{ background: "#fff", color: "#0d0e0f" }} />Dark mode is a class on <html> (class="dark"); the semantic tokens already carry the dark
values. A future brand variant swaps the primitive palette without touching component code, which is
why the primitive and semantic layers are kept separate. To roll out a theme change across every
app, edit the tokens, bump the package version, and let apps update the dependency. See the live
values on Tokens and the rules in Design guidelines.
Dependency injection
Frontend apps use inversify-hooks so that use-cases and API clients sit behind interfaces and can
be swapped in tests. Depend on an interface, register the implementation once in a composition root
under app/, and resolve it where you need it:
// contract lives with the entity/feature
export interface UserApi {
getById(id: string): Promise<User>;
}
// composition root under app/: bind the interface to an implementation
container.bind<UserApi>(TYPES.UserApi).to(HttpUserApi);Components resolve the contract through the hook the library provides, so a test binds a fake
instead. Because Inversify uses decorators, apps need experimentalDecorators and
useDefineForClassFields: false (already set in tsconfig.base.json) plus decorator support in the
Next.js compiler. The exact hook API is in the
inversify-hooks docs. The rule underneath is simple:
depend on abstractions, not concretions.
Components
Use shadcn/ui components directly. Do not wrap them in your own abstraction "just in case", and do not build a parallel component library. They are themed by the tokens with no extra work. Browse the Components catalog for each one with usage code.
App-specific components live in the app, in the slice that owns them. A component moves into the foundation only after the same real need shows up in more than one app. See Contributing for that bar.
Data fetching
Prefer the server for server data: fetch in Server Components or Server Actions and pass the result down. Reach for TanStack Query when you need a client-side cache, background refetching, or optimistic updates. Keep query keys structured and colocated with the feature that owns them.
Forms
Forms use react-hook-form with a zod schema for validation, wired through the shadcn Form
components:
const schema = z.object({ email: z.string().email() });
const form = useForm({ resolver: zodResolver(schema) });Validate on the client and show errors inline. Always design the empty, loading, success, and error states, not just the happy path.
Internationalization
Every user-facing string goes through react-i18next. This is mandatory, and the rule is absolute:
zero magic strings. That includes the ones easy to forget, such as aria-label on an icon-only
button, placeholder text, empty-state copy, validation messages, and date format patterns.
const { t } = useTranslation();
// Good
<Button aria-label={t("invoice.actions.delete")}>
<Trash2 />
</Button>
// Avoid: invisible to translators, and impossible to audit
<Button aria-label="Delete invoice">
<Trash2 />
</Button>Two consequences worth planning for. Text expands, often by 30% or more, so verify layouts with a
longer language rather than only English. And date and number formatting must use the same locale as
the active language, which means passing the matching date-fns locale rather than relying on the
browser default. For right-to-left languages, wrap the app in the DirectionProvider from the
Direction component.
Testing
Vitest with Testing Library covers unit and component tests, colocated as *.test.tsx next to the
code. Test a slice through its public API, not its internals, so tests survive refactors. Playwright
covers end-to-end flows. Anything with real logic ships tests; pure presentational wrappers do not
need them.
Common pitfalls
- Hardcoding colors or spacing instead of using tokens. It breaks dark mode and theme updates.
- Wrapping shadcn components or forking the theme in an app.
- Deep-importing across slices instead of going through
index.ts. - Reaching from a lower layer up into a higher one.
Skills for agents
Install these so an agent writes on-stack code: vercel-react-best-practices,
feature-sliced-design, shadcn, tailwind-design-system, tanstack-query-best-practices,
typescript-advanced-types, vitest-testing, accessibility. The full list with install commands
is on the AI and agents page.