ワンクリックで
feature-scaffold
// 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.
// 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.
Create and modify integration channels (messenger, whatsapp, zalo, webchat, etc.) for the chatbot platform. Use when adding a new channel integration, modifying webhook handlers, working with message send/receive, or connecting external platforms.
Create and modify oRPC API routers, procedures, and middleware for the builder app. Use when adding API endpoints, creating routers, defining procedures, working with oRPC middleware, or building OpenAPI routes.
Manage turborepo monorepo development workflow including dev servers, builds, linting, and package management. Use when running dev, build, lint, deploy, or managing workspace packages in this pnpm + turbo monorepo.
Create and manage background workers, BullMQ queues, Kafka consumers, and scheduled jobs. Use when adding new workers, creating queues, defining job types, building scheduled tasks, or working with async processing.
Work with Drizzle ORM database schema, migrations, relations, and queries in PostgreSQL. Use when creating tables, modifying schema, writing migrations, defining relations, or querying the database.
| 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. |
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.
// 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>
)
}
"use client")params and searchParams are Promise<...> (Next.js 15+ style)getIdFromParams() to extract and validate IDsPromise.all([...]) as promises prop to client componentsReact.use(promises)listItemsSearchParamsCache.parse())Some features expose unauthenticated URLs (tracking links, asset callbacks, etc.):
apps/builder/src/app/<public-prefix>/.../route.ts (or page.tsx when appropriate).publicRoutes in apps/builder/src/proxy.ts so the middleware does not force sign-in for those paths.lib/* module when the same logic might be reused or tested.apps/builder/src/features/tools/tools-list.tsx (with getLink) and add i18n keys under apps/builder/messages/*.json."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
)
}
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 })
},
)
workspaceActionClient — requires workspace membershipauthActionClient — requires authenticated session only// queries/index.ts
import { db } from "@chatbotx.io/database/client"
export const listItems = async (params: ListItemsParams) => {
return db.query.myModel.findMany({
where: { workspaceId: params.workspaceId },
with: { tags: true },
})
}
// RSC wrapper with auth check
export const listItemsRSC = async (params: ListItemsParams) => {
await assertCurrentUserCanAccessChatbot(params.workspaceId)
return listItems(params)
}
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>
)
}
.bind() for actions with bindArgsSchemasWhen 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()
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).
| 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 |
() organize without URL segments: (settings), (has-folder), (ai)@slot for multi-panel layouts (e.g. channels settings)space/[workspaceId]/layout.tsx: auth, sidebar, workspace contextAll user-facing text MUST be internationalized using next-intl. Never hardcode labels, placeholders, messages, or button text.
import { useTranslations } from "next-intl"
const t = useTranslations()
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 |
fields.* DefinitionsForm 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 Addressen.json first)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).
// 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"
/>
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" })
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"))
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")
Use messages.*:
t("messages.deleteConfirmation", { feature: "contact" })
t("messages.disconnectFeatureDescription", { feature: "SMTP" })
Before submitting any feature:
t()fields.* — check existing field definitions before creating new onesen.json, add it{feature}, {name} params<featureName>.*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.
src/features/<name>/schema/queries/actions/ (if mutations needed)api/ (if API access needed)src/routers/index.tssrc/app/space/[workspaceId]/...apps/builder/messages/en.json — reuse fields.* for form labels, add feature-specific text under <featureName>.*useTranslations() + t()