| name | frontend |
| description | Write Next.js frontends — generated hooks, component library, Tailwind v4, visual verification, and Connect RPC clients. |
Frontend Development in Forge
Project Structure
Each frontend lives in frontends/<name>/ as a Next.js app with App Router. Create one with:
forge add frontend <name>
Key directories inside frontends/<name>/:
src/app/ — Next.js App Router pages and layouts
src/components/ — Reusable React components
src/hooks/ — Generated and custom hooks
src/lib/ — Utilities and Connect RPC client setup
Generated TypeScript clients live in gen/ at the project root, shared across all frontends.
Production build shape (output:)
forge add frontend emits a next.config.ts configured for the common forge shape: a Next.js shell that calls a Go backend via Connect RPC. The default is standalone — production builds emit a self-contained Node server at .next-prod/standalone/server.js, which is the shape the shipped Dockerfile copies into its runner image, and the only default that builds with the dynamic [id] detail/edit routes forge generates for every CRUD entity.
The choice is captured in forge.yaml:
frontends:
- name: admin
type: nextjs
path: frontends/admin
port: 3000
output: standalone
Three values are accepted:
output: | Production shape | Use when |
|---|
standalone | Node sidecar (output: "standalone") | The default. Pairs with the shipped Dockerfile; supports the generated dynamic CRUD routes, server components, server actions, request-time redirect() / cookies(). |
static | Static export (output: "export") | Pure UI shell with NO dynamic routes — drop out/ on a CDN or object store. |
server | Full Next.js (no output: field) | Custom server, ISR, managed host (Vercel) where you want next start semantics. |
Opt into a non-default at scaffold time:
forge add frontend dashboard --output static
static is incompatible with generated CRUD pages. output: "export" requires generateStaticParams() on every dynamic route segment, and the generated detail/edit pages (/<entity>/[id]) are dynamic client routes whose ids only exist at runtime — npm run build fails on any project with a CRUD entity. Choose static only when the frontend has no dynamic routes (or you hand-write static params). The production artifact is then the contents of out/; the shipped Dockerfile (sized for standalone) can be ignored or deleted.
The output: field only takes effect at scaffold time; next.config.ts is Tier-2 (yours to edit after scaffold) so changing the YAML later does not retroactively rewrite the file — re-scaffold with forge generate --force or hand-edit.
Build dirs are fenced. In standalone/server modes, production builds write to .next-prod (via a distDir conditional in next.config.ts) while next dev keeps .next — so running npm run build while forge up's dev server is live cannot clobber the dev cache. next start and the Dockerfile both read .next-prod. The static mode keeps Next.js defaults (out/ export, .next intermediates) because export mode repurposes a custom distDir as the export destination; avoid production builds during a live dev session in that mode.
If a frontend uses server-runtime APIs (redirect() from next/navigation, cookies(), server actions) it MUST use output: standalone (the default) or output: server — those calls don't work in a static export.
For a root-route redirect (e.g. / → /dashboard) under static export, do NOT use redirect() from next/navigation — it requires the Next.js server runtime. Use a client component with useRouter().replace():
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function RootPage() {
const router = useRouter();
useEffect(() => {
router.replace("/dashboard");
}, [router]);
return null;
}
Serving under a path prefix (base_path)
To mount a frontend under a URL prefix (e.g. /admin behind a proxy that blends it with another app), declare it in forge.yaml:
frontends:
- name: admin
type: nextjs
base_path: /admin
(or scaffold with forge add frontend admin --base-path /admin). What this drives:
next.config.ts sets both basePath and assetPrefix to the same value — assetPrefix is required or some RSC chunk URLs skip the prefix and React never hydrates.
- One env var:
NEXT_PUBLIC_BASE_PATH is the only override forge reads or writes — the same name in next.config.ts and in the browser bundle. Never invent a second variant (ADMIN_WEB_BASE_PATH, etc.); it will be silently ignored.
src/lib/basepath_gen.ts (Tier-1, regenerated every forge generate) exports BASE_PATH and joinBasePath(path).
- Static-export builds fail loudly if
NEXT_PUBLIC_BASE_PATH is overridden to empty while forge.yaml declares a prefix — a baked root-mounted export 404s behind the proxy.
Rules of thumb:
- Internal navigation: keep using
<Link href="/tasks"> and router.push("/tasks") with app-relative paths — Next.js prepends the basePath automatically. Do NOT wrap these in joinBasePath (harmless — it's idempotent — but noise).
- Hand-built URLs Next.js can't see —
window.location.origin-based payment return URLs, OAuth redirect_uri, share links, raw fetch()/<a> paths — go through joinBasePath:
import { joinBasePath } from "@/lib/basepath_gen";
const successUrl = window.location.origin + joinBasePath("/billing/success");
Lint-worthy anti-patterns: bare "/admin" + path string literals (break the day the mount point changes), bare /route strings in hand-built URLs (bypass the prefix entirely), and reading any env var other than NEXT_PUBLIC_BASE_PATH.
For React Native mobile frontends, forge add frontend <name> --kind mobile creates an Expo app with the same systems:
app/ — Expo Router screens and layouts
src/hooks/ — Generated Connect RPC hooks (shared template)
src/lib/ — Connect client, event bus, auth provider
src/stores/ — Zustand stores (mobile-adapted: drawer, bottom sheet)
Generated TypeScript Hooks
forge generate produces per-service React Query hooks in src/hooks/. Read RPCs get useQuery hooks, mutating RPCs get useMutation hooks. Import from the barrel:
import { useGetTask, useCreateTask, useListTasks } from "@/hooks";
Do not hand-edit *-hooks.ts files — they are overwritten on forge generate. For custom hooks, create separate files in src/hooks/ (e.g., src/hooks/custom-hooks.ts).
Base wrappers useApiQuery and useApiMutation in src/hooks/ are available for one-off or composite operations that don't map to generated hooks.
Using the Component Library
Use the component_library tool to find production-ready components before building from scratch:
component_library(action="search", query="dashboard")
component_library(action="search", tag="chart")
component_library(action="get", name="quadrant_chart")
Categories: layouts, charts, diagrams, deck, ui.
Data viz: use Recharts, not the library
For commodity data viz — bar, line, area, donut, pie, scatter, sparkline — install Recharts:
npm i recharts
Hand-rolling SVG paths for these loses to a real library on every dimension that matters: hover tooltips, click handlers, brush selection, datetime/log axes, locale formatting, dual axes, canvas rendering past ~1k points, accessibility. Don't compete with hundreds of person-years of investment.
The component library only ships narrative charts — quadrant_chart (competitive matrix), concentric_circles (TAM/SAM/SOM), funnel_chart (sales conversion) — and the slide_* deck charts. These are presentation-grade where heavy customization matters more than interactivity, and where libraries are weakest.
Rule of thumb: if it's going on a dashboard, use Recharts. If it's going on a slide or a marketing page, check the component library first.
Base UI primitives (always available)
Every forge frontend ships a small set of low-level primitives at scaffold time, under src/components/ui/. Pages and frontend packs MUST compose these instead of inlining their own <button> / <input> / <table> markup. The full set:
| Primitive | Import | What it is |
|---|
button | import Button from "@/components/ui/button" | Generic button — primary / secondary / outline / ghost / danger variants, sizes, loading state. |
input | import Input from "@/components/ui/input" | Generic text input — sizes, invalid state, forwarded ref. Pair with <Label>. |
label | import Label from "@/components/ui/label" | Form field label with optional required-asterisk. |
form | import Form, { FormField, FormError, FormActions } from "@/components/ui/form" | Form structural primitives — root <form> plus field/error/actions wrappers. <FormField> mints an id and exposes it via FormFieldContext so child <Label> / <Input> / <Select> auto-bind without htmlFor / id boilerplate. |
card | import Card, { CardHeader, CardBody, CardFooter } from "@/components/ui/card" | Generic surface primitive. Distinct from MetricCard/StatCards (domain components). |
avatar | import Avatar from "@/components/ui/avatar" | User avatar with image, initials fallback, status indicator. |
tabs | import Tabs from "@/components/ui/tabs" | Tab navigation with underline/pills/boxed variants. |
table | import Table, { TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table" | Bare structural table — pair with @tanstack/react-table for headless sort/filter. |
select | import Select from "@/components/ui/select" | Generic select — options array, sizes, invalid state. |
chip | import Chip from "@/components/ui/chip" | Removable filter chip / tag. Distinct from Badge (status-shaped). |
toast_notification | import { ToastProvider, useToast } from "@/components/ui/toast_notification" | Toast/notification system — success/error/warning/info, auto-dismiss. |
Plus the higher-level domain components scaffolded out of the box: sidebar_layout, page_header, badge, modal, skeleton_loader, pagination, search_input, alert_banner, key_value_list, login_form.
Two of those higher-level components have well-defined canonical APIs
plus accepted aliases — write new code against the canonical names,
keep the aliases only as a migration-friendly back door:
-
Badge — canonical variants are error / success / warning /
info / neutral. Aliases: danger → error, default → neutral.
The aliases exist for source-port compatibility (codebases that name
the destructive badge danger and the chrome-less badge default
don't need an adapter table at every call site). New code should use
the canonical names; reach for an alias only when porting existing
code and rewriting every call site would be churn.
-
Modal — accepts a footer EITHER as a footer slot prop OR
embedded inside children. Canonical is the footer prop — it
composes cleanly with <Modal.Body>-style headers/bodies, keeps the
footer styled by the Modal itself (border, padding, button alignment),
and survives any future Modal API evolution. The
footer-in-children shape is a source-port shorthand; rewrite to the
slot prop when you next touch the code.
<Modal
open={open}
onClose={close}
title="Delete project"
footer={
<>
<Button variant="ghost" onClick={close}>Cancel</Button>
<Button variant="danger" onClick={confirm}>Delete</Button>
</>
}
>
Are you sure?
</Modal>
<Modal open={open} onClose={close} title="Delete project">
<p>Are you sure?</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={close}>Cancel</Button>
<Button variant="danger" onClick={confirm}>Delete</Button>
</div>
</Modal>
These primitives are written as overwrite: once from the scaffolder — once installed, they are yours to edit. If you find yourself re-inlining a button or input shape in a page or pack, stop and use the primitive instead.
Form field auto-binding
<FormField> mints a unique id via React.useId() and provides it
through FormFieldContext. Child <Label> reads the context for
htmlFor; child <Input> / <Select> reads the context for id.
The page-author writes neither:
<Form>
<FormField>
<Label required>Email</Label>
<Input type="email" value={email} onChange={onEmail} />
</FormField>
<FormField>
<Label>Plan</Label>
<Select
options={[{ value: "pro", label: "Pro" }, { value: "team", label: "Team" }]}
/>
</FormField>
</Form>
Clicking either label focuses its input. Explicit htmlFor on the
Label or id on the input still wins, so deterministic ids (for tour
highlights, aria-describedby from another node, etc.) remain
straightforward. Custom form controls can opt into the same pattern by
reading FormFieldContext themselves — see the doc comment in
@/components/ui/form.
Variant naming conventions
Button ships primary | secondary | outline | ghost | danger —
destructive action is danger, not error, because Button is
action-shaped and Badge is status-shaped. (Badge's destructive
variant has the opposite spelling for the same reason — see
the Badge entry above for canonical names and accepted aliases.)
Connect RPC Clients
Import the generated Connect transport from src/lib/connect.ts (generated — do not edit):
import { useGetTask } from "@/hooks";
import { createClient } from "@connectrpc/connect";
import { MyService } from "@/../../gen/my/service/v1/service_connect";
import { transport } from "@/lib/connect";
export const myServiceClient = createClient(MyService, transport);
Real backend by default; mock mode is opt-in
The scaffold talks to the REAL backend out of the box: src/lib/connect.ts
reads NEXT_PUBLIC_API_URL (or the forge generate-maintained dev port in
src/lib/apiurl_gen.ts) and builds a real Connect transport. Start the
backend with forge up --env=dev and the UI is live end-to-end.
Mock mode exists for offline UI-only work and is opt-in via
.env.local:
NEXT_PUBLIC_MOCK_API / VITE_MOCK_API | Behavior |
|---|
| unset (default) | Real backend. RPC failures surface as real errors. |
true | Mock transport only — the backend is NEVER contacted. The layout renders a persistent "MOCK DATA — backend not connected" banner so a working-looking UI can't masquerade as a working stack. |
hybrid | ?scenario= overlays on a real transport (see the scenarios sub-skill). Banner shows hybrid mode. |
Never remove the mock banner from the layout: silent mock mode is exactly
the failure mode it exists to prevent.
Protobuf-ES v2 Patterns
Forge uses protobuf-es v2. Create message instances with create(), not constructors:
import { create } from "@bufbuild/protobuf";
import { CreateTaskRequestSchema } from "@/../../gen/my/service/v1/service_pb";
const req = create(CreateTaskRequestSchema, {
name: "My Task",
description: "Details here",
});
Styling with Tailwind CSS v4
This project uses Tailwind CSS v4. Key differences from v3:
- No
tailwind.config.js — configuration is done in CSS with @theme
- Import with
@import "tailwindcss" in your global CSS (not @tailwind base/components/utilities)
- PostCSS plugin is
@tailwindcss/postcss (not tailwindcss)
- Use
@theme blocks in CSS for custom design tokens instead of a config file
@import "tailwindcss";
@theme {
--color-brand: #3b82f6;
--font-sans: "Inter", sans-serif;
}
Use Tailwind utility classes directly in JSX. Follow responsive-first design (sm:, md:, lg: prefixes).
CSS Health
Treat CSS as architecture, not a dumping ground. Keep styling predictable and easy to refactor:
- Prefer Tailwind utilities and component variants for layout, spacing, color, and state styling.
- Use CSS variables in
@theme or scoped CSS for reusable design tokens; do not hard-code one-off colors across components.
- Avoid
!important. If specificity fights you, simplify selectors, move styles closer to the component boundary, or introduce a variant API.
- Avoid DOM
style={{...}} props except for truly dynamic runtime values (measured dimensions, CSS custom properties, chart coordinates). Prefer className and CSS variables.
- Keep global CSS small: Tailwind import, theme tokens, base element defaults, and app-wide variables only.
- Run
npm run lint:styles when changing CSS-heavy files; it catches !important and invalid Tailwind v4 at-rules.
Visual Verification
ALWAYS use BOTH take_snapshot AND take_screenshot via Chrome DevTools before declaring frontend work complete.
Snapshots (a11y tree) alone cannot detect CSS/visual issues — layout shifts, misaligned elements, wrong colors, broken responsive layouts, z-index problems, and overflow issues are invisible to snapshots. Screenshots catch what snapshots miss.
# After making changes, verify with BOTH:
take_snapshot() # Check element structure, text content, accessibility
take_screenshot() # Check visual layout, styling, spacing, colors
For responsive testing, resize the page and screenshot at multiple breakpoints.
Component Patterns
- Use functional components with hooks exclusively — no class components.
- Prefer server components by default. Add
"use client" only when you need interactivity, browser APIs, or hooks like useState/useEffect.
- Keep components small and focused. Extract reusable logic into custom hooks.
Error Handling
Handle Connect RPC errors by code:
import { ConnectError, Code } from "@connectrpc/connect";
try {
const res = await myServiceClient.createItem({ name: "example" });
} catch (err) {
if (err instanceof ConnectError) {
if (err.code === Code.InvalidArgument) {
setFieldErrors(err.message);
} else if (err.code === Code.PermissionDenied) {
setError("You don't have permission to do this.");
} else {
setError("Something went wrong. Please try again.");
}
}
}
Every data-fetching component must handle three states: loading, success, and error.
Files NOT to Edit
These files are regenerated by forge generate — changes will be overwritten:
src/gen/ — Generated TypeScript stubs and clients
src/lib/connect.ts — Connect transport setup
src/lib/basepath_gen.ts — BASE_PATH + joinBasePath() from forge.yaml's frontends[].base_path
src/hooks/*-hooks.ts — Generated React Query hooks
Put custom code in separate files alongside them (e.g., src/hooks/custom-hooks.ts, src/lib/utils.ts).
Scaffolded Infrastructure (yours to extend)
These files are created by forge add frontend and are yours to modify:
src/lib/auth/ — Auth provider interface, stub provider, context. Implement AuthProvider to add real auth.
src/lib/events.ts — Typed event bus. Extend the EventMap interface to add custom events.
src/lib/event-context.tsx — Event bus React context and hooks.
src/stores/ui-store.ts — Zustand base UI store. Extend or create domain stores in src/stores/.
src/lib/format-utils.ts — Shared formatting utilities used by generated pages.
src/lib/admin-url.ts — adminUrl(path) + absoluteAdminUrl(path) convenience wrappers over the generated src/lib/basepath_gen.ts (the single source of truth for the prefix; see "Serving under a path prefix"). Use these (or joinBasePath directly) for any string passed to an external system that round-trips back to this frontend (Stripe success_url, OAuth redirect_uri, share links, magic-link emails) — the basePath leaks through <Link> for free but NOT into raw URL strings.
Dev Workflow
forge up --env=dev
Changes to frontend code reflect instantly in the browser. After changing .proto files, always regenerate:
forge generate
How forge up runs the frontend
forge up dev-serves each declared forge.Frontend via npm run dev
— it does NOT run npm run build in the loop. The prod build is for
forge build / forge deploy, not the inner-loop. Dev edits hot-reload
without a build step.
The frontend's declared port in KCL (forge.Frontend.port) is
force-injected as the PORT env var into the Next.js child
process. Next.js binds the declared port even if a stale PORT=...
bled in from the parent shell — drift between the KCL-declared port,
the generated docker-compose, and the actual dev server is now
structurally impossible.
Multi-frontend dev URLs (declared ports)
Under forge up --env=dev, each frontend dev-serves directly on its
own KCL-declared port (forge.Frontend.port) — there is no dev
reverse proxy. Open each frontend at its declared port:
http://localhost:<admin-port> → the admin frontend (KCL port)
http://localhost:<web-port> → the web frontend (KCL port)
http://localhost:<api-port> → the api service (KCL port)
The declared port is force-injected as the PORT env var into each
Next.js child process, so the browser URL always matches the KCL
declaration — drift between the KCL port and the actual dev server is
structurally impossible. Next.js HMR works directly against each
dev server.
Adding a service: declare an HTTPRoute in your
deploy/kcl/<env>/main.k with a host: value so it routes the same
way under the production Gateway:
forge.HTTPRoute {
name = "api-route"
service = "api"
port = 8000
host = "api.localhost" # used by the prod Gateway
}
Scaffolded Systems
The frontend scaffold includes three extensible systems:
- Auth provider (
src/lib/auth/) — DI'd via AuthProvider. Swap in Auth0, Clerk, or custom JWT by implementing the AuthProvider interface. useAuth() gives you user, token, login, logout.
- Event bus (
src/lib/events.ts) — Typed pub/sub for imperative cross-cutting actions (toast:show, auth:expired, navigate). Extend the event map with your own events. Use useEvent(name, handler) in components.
- UI store (
src/stores/ui-store.ts) — Zustand baseline for shared client state (sidebarCollapsed, commandPaletteOpen). Extend or create domain stores in src/stores/.
Mobile (React Native) frontends include the same three systems adapted for mobile:
- Auth provider — same
AuthProvider interface, same useAuth() hook
- Event bus — same typed pub/sub, plus mobile-specific events (
app:background, app:foreground)
- UI store — mobile-adapted:
drawerOpen, bottomSheetOpen instead of sidebarCollapsed, commandPaletteOpen
File naming inside frontends/<name>/src/
Forge templates follow two TS file-naming conventions, both intentional:
- Components under
src/components/ui/ are snake_case (data_table.tsx, toast_notification.tsx, key_value_list.tsx). Each file default-exports a single PascalCase component (Button, DataTable).
- Hooks, lib utilities, and stores are
kebab-case (use-api-query.ts, format-utils.ts, ui-store.ts).
For the full Go / proto / TS / forge.yaml casing table, see architecture → Naming conventions.
Sub-skills
Load sub-skills for specific frontend topics:
- state — State management decision table, state vs events, ownership, async handling
- patterns — Component composition, container/presentational, effects discipline, typed boundaries
For frontend testing — what to test, what NOT to test, the mockTransport() seam, and recipes per layer — see the top-level frontend-testing skill.
Rules
- Never hand-edit generated files (
*-hooks.ts, src/gen/, src/lib/connect.ts).
- Always use
create(Schema, {...}) for protobuf messages, never new Message().
- Run
forge generate after every .proto change.
- Add
"use client" only when needed — hooks and event handlers require client components.
- Verify frontend work visually with BOTH
take_snapshot and take_screenshot.
- Use the
component_library tool before building UI components from scratch.
- Use generated React Query hooks for server data — do not copy backend data into Zustand.
- Subscribe to Zustand slices, not the whole store.
- Use the event bus for imperative actions (toasts, navigation commands) — not as a source of truth.
- Keep forms in react-hook-form + Zod — do not hand-roll form state with scattered useState.