원클릭으로
frontend
Write Next.js frontends — generated hooks, component library, Tailwind v4, visual verification, and Connect RPC clients.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Write Next.js frontends — generated hooks, component library, Tailwind v4, visual verification, and Connect RPC clients.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Visual-design discipline for forge frontends — brief the work before building (never invent an aesthetic without user input), declare the design system before coding, lean on the component library, use restrained color/type systems, never hand-draw complex SVGs, and verify visually before declaring done.
Outbound boundary translators. One adapter per third-party system / queue / storage backend; narrow interface, vendor-neutral callers.
Write Connect RPC handlers — proto-driven codegen, the thin-translation handler pattern (validate, extract auth, convert proto↔internal, call service, wrap errors via `svcerr.Wrap`), middleware, and testing. Business logic lives in `internal/handlers/<svc>/contract.go`, never in handlers.
Forge project conventions and architecture — project structure, generated vs hand-written code, the generate pipeline, proto annotations, contracts, wiring, and naming.
Use-case orchestrators that compose two or more adapters/services. Deps are interfaces only — designed for unit tests with all-mock collaborators.
Proto file reference — annotations, CRUD conventions, field rules, and common mistakes.
| name | frontend |
| description | Write Next.js frontends — generated hooks, component library, Tailwind v4, visual verification, and Connect RPC clients. |
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 layoutssrc/components/ — Reusable React componentssrc/hooks/ — Generated and custom hookssrc/lib/ — Utilities and Connect RPC client setupGenerated TypeScript clients live in gen/ at the project root, shared across all frontends.
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 # default — production = Node server, dev = next dev
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;
}
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 # must start with "/", no trailing "/"
(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.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).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:
<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).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 layoutssrc/hooks/ — Generated Connect RPC hooks (shared template)src/lib/ — Connect client, event bus, auth providersrc/stores/ — Zustand stores (mobile-adapted: drawer, bottom sheet)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.
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.
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.
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.
// Canonical: footer slot prop
<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>
// Accepted (source-port shorthand): footer inline in children
<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.
<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.
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.)Import the generated Connect transport from src/lib/connect.ts (generated — do not edit):
import { useGetTask } from "@/hooks";
// or for direct calls:
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);
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.
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";
// CORRECT — protobuf-es v2
const req = create(CreateTaskRequestSchema, {
name: "My Task",
description: "Details here",
});
// WRONG — this is protobuf-es v1 syntax
// const req = new CreateTaskRequest({ name: "My Task" });
This project uses Tailwind CSS v4. Key differences from v3:
tailwind.config.js — configuration is done in CSS with @theme@import "tailwindcss" in your global CSS (not @tailwind base/components/utilities)@tailwindcss/postcss (not tailwindcss)@theme blocks in CSS for custom design tokens instead of a config file/* src/app/globals.css */
@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).
Treat CSS as architecture, not a dumping ground. Keep styling predictable and easy to refactor:
@theme or scoped CSS for reusable design tokens; do not hard-code one-off colors across components.!important. If specificity fights you, simplify selectors, move styles closer to the component boundary, or introduce a variant API.style={{...}} props except for truly dynamic runtime values (measured dimensions, CSS custom properties, chart coordinates). Prefer className and CSS variables.npm run lint:styles when changing CSS-heavy files; it catches !important and invalid Tailwind v4 at-rules.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.
"use client" only when you need interactivity, browser APIs, or hooks like useState/useEffect.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.
These files are regenerated by forge generate — changes will be overwritten:
src/gen/ — Generated TypeScript stubs and clientssrc/lib/connect.ts — Connect transport setupsrc/lib/basepath_gen.ts — BASE_PATH + joinBasePath() from forge.yaml's frontends[].base_pathsrc/hooks/*-hooks.ts — Generated React Query hooksPut custom code in separate files alongside them (e.g., src/hooks/custom-hooks.ts, src/lib/utils.ts).
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.forge up --env=dev # Full stack: infra + Go (hot reload) + Next.js, reads deploy/kcl/<env>/
Changes to frontend code reflect instantly in the browser. After changing .proto files, always regenerate:
forge generate
forge up runs the frontendforge 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.
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
}
The frontend scaffold includes three extensible systems:
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.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.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:
AuthProvider interface, same useAuth() hookapp:background, app:foreground)drawerOpen, bottomSheetOpen instead of sidebarCollapsed, commandPaletteOpenfrontends/<name>/src/Forge templates follow two TS file-naming conventions, both intentional:
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).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.
Load sub-skills for specific frontend topics:
For frontend testing — what to test, what NOT to test, the mockTransport() seam, and recipes per layer — see the top-level frontend-testing skill.
*-hooks.ts, src/gen/, src/lib/connect.ts).create(Schema, {...}) for protobuf messages, never new Message().forge generate after every .proto change."use client" only when needed — hooks and event handlers require client components.take_snapshot and take_screenshot.component_library tool before building UI components from scratch.