Platform Foundationsv0.2.0

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.

ConcernChoice
LanguageTypeScript (strict)
FrameworkNext.js (App Router) + React
Componentsshadcn/ui on Radix
StylingTailwind CSS v4
Theme@plainconceptsplatform/ui-theme
IconsLucide (lucide-react)
Dependency injectioninversify-hooks
Internationalizationreact-i18next + i18next
Data fetchingTanStack Query (or RSC / Server Actions)
Formsreact-hook-form + zod
Lint and formatBiome
TestsVitest + 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, hooks

Two 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.