name: feature-scaffold description: >- Scaffold new features following project conventions for the builder app. Use when creating a new feature, page, component, server action, query, or adding a new section to the web application.
Feature Scaffold
Feature Directory Structure
Features live in apps/builder/src/features/<feature-name>/. Standard layout:
features/<feature-name>/
actions/ → Server actions (next-safe-action)
create-item-action.ts
delete-item-action.ts
api/ → oRPC route handlers
index.ts
authenticated.ts
workspace-token.ts
queries/ → Server-side DB queries
index.ts
schema/ → Zod schemas
query.ts → List/filter params
action.ts → Mutation inputs
resource.ts → Response shapes
provider/ → Zustand store + context (if needed)
item-store.ts
item-store-provider.tsx
components/ → UI components (if many)
hooks/ → Feature-specific hooks (if needed)
item-table.tsx → Root-level components (if few)
create-item-dialog.tsx
Not every feature needs all directories. Use what's appropriate.
Page Pattern (Server Component)
// app/space/[workspaceId]/(has-folder)/<feature>/page.tsx
import { Suspense } from "react"
import { getIdFromParams } from "@/lib/params"
import { listItems } from "@/features/<feature>/queries"
import { ItemsTable } from "@/features/<feature>/items-table"
export default async function ItemsPage(props: {
params: Promise<{ workspaceId: string }>
searchParams: Promise<SearchParams>
}) {
const workspaceId = getIdFromParams(await props.params, "workspaceId")
const searchParams = await props.searchParams
const search = listItemsSearchParamsCache.parse(searchParams)
const promises = Promise.all([
listItems({ ...search, workspaceId }),
])
return (
<Suspense>
<ItemsTable promises={promises} workspaceId={workspaceId} />
</Suspense>
)
}
Key Page Patterns
- Pages are async server components (no
"use client") paramsandsearchParamsarePromise<...>(Next.js 15+ style)- Use
getIdFromParams()to extract and validate IDs - Pass
Promise.all([...])aspromisesprop to client components - Client components unwrap with
React.use(promises) - URL state via nuqs (
listItemsSearchParamsCache.parse())
Public routes (short links, webhooks, open redirects)
Some features expose unauthenticated URLs (tracking links, asset callbacks, etc.):
- Implement the handler as a Route Handler under
apps/builder/src/app/<public-prefix>/.../route.ts(orpage.tsxwhen appropriate). - Add the URL prefix to
publicRoutesinapps/builder/src/proxy.tsso the middleware does not force sign-in for those paths. - Put redirect rules, URL templating, and validation in a small
lib/*module when the same logic might be reused or tested. - Register new Tools entries in
apps/builder/src/features/tools/tools-list.tsx(withgetLink) and add i18n keys underapps/builder/messages/*.json.
Client Component Pattern
"use client"
import { use } from "react"
type Props = {
promises: Promise<[ItemList]>
workspaceId: string
}
export const ItemsTable = ({ promises, workspaceId }: Props) => {
const [items] = use(promises)
return (
// Table UI using @chatbotx.io/ui components
)
}
Server Actions
Use next-safe-action with workspace-scoped client:
// actions/create-item-action.ts
"use server"
import { workspaceActionClient } from "@/lib/safe-action"
import { createItemRequest } from "../schema/action"
export const createItemAction = workspaceActionClient
.bindArgsSchemas([z.string()]) // workspaceId
.inputSchema(createItemRequest)
.action(
async ({
bindArgsParsedInputs: [workspaceId],
parsedInput,
}) => {
return await createItem({ workspaceId, ...parsedInput })
},
)
Tests
- Put builder app-level tests for actions, routes, API behavior, cache behavior,
or cross-feature behavior in
apps/builder/__tests__/. - Use colocated
src/features/**/__tests__only for narrow component/unit tests that are clearly owned by that feature.
Action Clients
workspaceActionClient— requires workspace membershipauthActionClient— requires authenticated session only
Queries (Server-Side)
Rule: Queries must NOT import db directly. Call a service from @chatbotx.io/business or a repository. See .agents/rules/data-access.md.
// queries/index.ts
import { itemService } from "@chatbotx.io/business"
export const listItems = async (params: ListItemsParams) => {
return itemService.list({ workspaceId: params.workspaceId })
}
// RSC wrapper with auth check
export const listItemsRSC = async (params: ListItemsParams) => {
await assertCurrentUserCanAccessChatbot(params.workspaceId)
return listItems(params)
}
Forms
Use React Hook Form + Zod + next-safe-action adapter:
"use client"
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks"
import { zodResolver } from "@hookform/resolvers/zod"
import { createItemAction } from "../actions/create-item-action"
import { createItemRequest } from "../schema/action"
export const CreateItemForm = ({ workspaceId }: { workspaceId: string }) => {
const { form, handleSubmitWithAction } = useHookFormAction(
createItemAction.bind(null, workspaceId),
zodResolver(createItemRequest),
{ formProps: { defaultValues: { name: "" } } },
)
return (
<form onSubmit={handleSubmitWithAction}>
{/* Form fields using @chatbotx.io/ui form components */}
</form>
)
}
Form Section Layout — Use <Card>
Multi-section forms (create/edit pages) use <Card> to group related fields. Never use a plain <div className="rounded-lg border p-6"> wrapper — always use the Card component.
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@chatbotx.io/ui/components/ui/card"
// Section with a title
<Card>
<CardHeader>
<CardTitle className="text-base">{t("feature.sections.pricing")}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<InputField ... />
<InputField ... />
</CardContent>
</Card>
// Section without a title (e.g. basic info / first card)
<Card>
<CardContent className="space-y-4 pt-6">
<InputField ... />
</CardContent>
</Card>
Full-page create/edit forms follow this layout:
<div className="flex min-h-screen flex-col bg-muted/20">
{/* Sticky top bar with title + Save/Cancel */}
<div className="sticky top-0 z-10 flex items-center justify-between border-b bg-background px-6 py-3">
<h1 className="font-semibold text-lg">{t("feature.create.title")}</h1>
<div className="flex items-center gap-2">
<Button onClick={() => router.back()} type="button" variant="ghost">
{t("actions.cancel")}
</Button>
<Button type="submit">{t("actions.save")}</Button>
</div>
</div>
{/* Scrollable form content */}
<Form {...form}>
<form className="mx-auto w-full max-w-3xl space-y-6 px-6 py-8" onSubmit={handleSubmitWithAction}>
<Card>...</Card>
<Card>...</Card>
</form>
</Form>
</div>
Watching Form Values — Use useWatch
To reactively read form field values in render, use useWatch from react-hook-form instead of form.watch(). useWatch is a proper React hook and avoids unnecessary re-renders of parent components.
import { useWatch } from "react-hook-form"
// WRONG — causes parent re-renders
const inventoryPolicy = form.watch("inventoryPolicy")
// CORRECT — scoped subscription
const inventoryPolicy = useWatch({ control: form.control, name: "inventoryPolicy" })
const longDescription = useWatch({ control: form.control, name: "longDescription" }) ?? ""
CRITICAL — .bind() for actions with bindArgsSchemas
When an action uses bindArgsSchemas (e.g. for workspaceId), you MUST call .bind(null, workspaceId) before passing to useHookFormAction. Without .bind(), TypeScript will error: "Target signature provides too few arguments."
// WRONG — will cause type error
useHookFormAction(createItemAction, zodResolver(schema), ...)
// CORRECT — bind the workspaceId first
useHookFormAction(createItemAction.bind(null, workspaceId), zodResolver(schema), ...)
Similarly for useAction with delete actions:
const { execute } = useAction(
deleteItemAction.bind(null, workspaceId, itemId),
{ onSuccess: ..., onError: ... },
)
// Call execute() with NO arguments (not execute({}))
execute()
State Management (Zustand)
For features needing client-side state:
// provider/item-store.ts
import { createStore } from "zustand/vanilla"
type ItemState = {
items: Item[]
selectedId: string | null
}
type ItemActions = {
setSelectedId: (id: string | null) => void
}
export type ItemStore = ItemState & ItemActions
export const createItemStore = (initial: Partial<ItemState> = {}) =>
createStore<ItemStore>((set) => ({
items: [],
selectedId: null,
...initial,
setSelectedId: (id) => set({ selectedId: id }),
}))
Wrap with React context provider (provider/item-store-provider.tsx).
Import Conventions
| What | Path |
|---|---|
| App internal | @/features/<feature>/..., @/lib/..., @/components/... |
| Shared UI | @chatbotx.io/ui/<component> |
| Database | @chatbotx.io/database/client, @chatbotx.io/database/schema |
| Types | @chatbotx.io/database/types |
| oRPC client | @/lib/orpc/orpc |
| oRPC stacks | @/orpc (for authorizedAPI, workspaceTokenAuthAPI) |
| Auth middleware | @/middlewares/auth |
| Safe action clients | @/lib/safe-action |
Layout Patterns
- Route groups
()organize without URL segments:(settings),(has-folder),(ai) - Parallel routes
@slotfor multi-panel layouts (e.g. channels settings) - Workspace layout at
space/[workspaceId]/layout.tsx: auth, sidebar, workspace context - Server layouts: auth checks, data loading
- Client layouts: tabs, accordions, interactive navigation
Internationalization (i18n) — next-intl
All user-facing text MUST be internationalized using next-intl. Never hardcode labels, placeholders, messages, or button text.
Setup
import { useTranslations } from "next-intl"
const t = useTranslations()
Translation File Structure
Translations live in apps/builder/messages/en.json. The file is organized into namespaces:
| Namespace | Purpose | Example |
|---|---|---|
fields.* |
Reusable field labels, placeholders, descriptions | fields.name.label, fields.email.placeholder |
actions.* |
Button/action labels | actions.cancel, actions.create, actions.save |
messages.* |
Toast messages, confirmations, descriptions | messages.createdSuccess, messages.deleteConfirmation |
<feature>.* |
Feature-specific text (titles, descriptions, unique labels) | smtp.setting.label, webchat.title |
Form Fields — Reuse fields.* Definitions
Form field label and placeholder props MUST use translations from the fields namespace in en.json. This ensures consistency across the entire app.
Pattern:
<InputField
label={t("fields.name.label")}
name="name"
placeholder={t("fields.name.placeholder")}
required
/>
<SelectField
label={t("fields.type.label")}
name="type"
options={options}
required
/>
Reusable fields already defined (check en.json → fields before creating new ones):
fields.name— Namefields.email— Emailfields.password— Passwordfields.description— Descriptionfields.type— Typefields.url— URLfields.status— Statusfields.provider— Providerfields.host— Hostfields.port— Portfields.username— Usernamefields.fromAddress— From Address- ... and many more (always check
en.jsonfirst)
Adding new field definitions — When a field doesn't exist in en.json, add it to the fields object:
{
"fields": {
"myNewField": {
"label": "My New Field",
"placeholder": "Enter value"
}
}
}
Each field entry can have: label (required), placeholder (optional), description (optional).
CRITICAL — Never Hardcode Labels in Forms
// WRONG — hardcoded label strings
<InputField label="Username" name="username" placeholder="user@example.com" />
<InputField label="Password" name="password" />
// CORRECT — use t() with fields namespace
<InputField
label={t("fields.username.label")}
name="username"
placeholder={t("fields.username.placeholder")}
/>
<InputField
label={t("fields.password.label")}
name="password"
/>
Actions (Buttons)
Use actions.* for all button labels:
<Button onClick={onCancel} type="button" variant="ghost">
{t("actions.cancel")}
</Button>
<Button type="submit">
{t("actions.create")}
</Button>
Common actions: actions.cancel, actions.create, actions.save, actions.delete, actions.update, actions.confirm, actions.connect, actions.disconnect.
Parametric actions with {feature} interpolation:
t("actions.createFeature", { feature: t("fields.sequences.label") })
t("actions.connectFeature", { feature: "WhatsApp" })
Toast Messages
Use messages.* with {feature} interpolation:
// Success
toast.success(t("messages.createdSuccess", { feature: "SMTP" }))
toast.success(t("messages.updatedSuccess", { feature: t("fields.webhook.label") }))
// Error — prefer translated messages, fallback to serverError
toast.error(error.serverError || t("messages.unknownError"))
Feature-Specific Translations
For text unique to a feature (not reusable), add a feature namespace:
{
"smtp": {
"setting": {
"description": "Send emails using your SMTP server.",
"label": "(Email) SMTP"
}
}
}
Access: t("smtp.setting.label"), t("smtp.setting.description")
Dialog / Confirmation Text
Use messages.*:
t("messages.deleteConfirmation", { feature: "contact" })
t("messages.disconnectFeatureDescription", { feature: "SMTP" })
i18n Checklist
Before submitting any feature:
- No hardcoded user-facing strings — every label, placeholder, button, message uses
t() - Reuse
fields.*— check existing field definitions before creating new ones - Add missing translations — if a field key doesn't exist in
en.json, add it - Use interpolation — for dynamic text, use
{feature},{name}params - Feature namespace — feature-specific text goes under
<featureName>.*
Logging
Never use console.log, console.error, or console.warn in server-side code (actions, queries, API handlers). Use the structured logger instead.
// ✅ correct — server action / query
import baseLogger from "@chatbotx.io/logger"
const logger = baseLogger.child({ feature: "myFeature" })
try {
return await doWork(input)
} catch (error) {
logger.error({ err: error }, "[myFeature] operation failed")
throw error
}
Use err: error (not error: error) — pino's serializer is keyed on err.
Client components may use console only for local development debugging that is removed before merge.
Services — Business Logic Belongs in @chatbotx.io/business
Business logic services (DB queries, domain mutations, cache management) MUST NOT be placed inside features/<name>/. They belong in packages/business/src/<domain>/service.ts.
Pattern
packages/business/src/<domain>/service.ts— class extendingBaseService, singleton exportpackages/business/src/<domain>/index.ts—export * from "./service"packages/business/src/index.ts— addexport * from "./<domain>"
Feature folders only contain
queries/— RSC wrappers that call business services + add auth checksactions/— next-safe-action handlers that call business servicesapi/— oRPC handlersschema/— Zod validation schemas (NOT imported by business package)components/,hooks/,provider/— UI concerns
Never create a *.service.ts inside a feature folder for new work. If one already exists, move it to @chatbotx.io/business before extending it.
import { integrationService, webhookService } from "@chatbotx.io/business"
Checklist for New Feature
- Create feature directory under
src/features/<name>/ - Define Zod schemas in
schema/ - Create DB queries in
queries/ - Add server actions in
actions/(if mutations needed) - Create oRPC API in
api/(if API access needed) - Register router in
src/routers/index.ts - Create page(s) under
src/app/space/[workspaceId]/... - Build UI components (server page → client table/form)
- Add i18n translations to
apps/builder/messages/en.json— reusefields.*for form labels, add feature-specific text under<featureName>.* - Verify no hardcoded strings — all user-facing text uses
useTranslations()+t()