一键导入
add-page
// Scaffold a new Next.js page with MUI layout, server-side auth guard, feature component, pagination, and optional create dialog following OGStack frontend conventions.
// Scaffold a new Next.js page with MUI layout, server-side auth guard, feature component, pagination, and optional create dialog following OGStack frontend conventions.
Scaffold a new backend API module with controller, service, schema, test, and optional repository files following OGStack conventions.
Stage changes and create a git commit with a well-crafted message following conventional commit style.
Create a pull request with a well-structured title and description based on branch commits.
| name | add-page |
| description | Scaffold a new Next.js page with MUI layout, server-side auth guard, feature component, pagination, and optional create dialog following OGStack frontend conventions. |
| argument-hint | <page-name> <route-path> [--auth] [--list] [--form] [--crud] |
Scaffold a new Next.js page under apps/web/src/app/ following OGStack's established frontend patterns. Generates a React Server Component page with a "use client" feature component, optional auth protection, data list with pagination, and optional create/edit forms.
$ARGUMENTS should contain:
projects, api-keys, playground) — used for file names and component names(dashboard)/projects) — the folder path under src/app/. Defaults to the page name.Optional flags:
--auth: Page requires authentication. Adds a server-side auth check using getServerClient() and redirects to /login if unauthenticated. (Recommended for all dashboard pages)--list: Generate a list view with search input, data table, and pagination controls (Prev/Next buttons with total count).--form: Generate a standalone form page using TanStack Form + Zod v4.--crud: Implies --list. Also generates a create dialog component as a separate file, with a "New X" button that opens it. Best for resource management pages.If no arguments are provided, ask the user for the page name and route.
Extract from $ARGUMENTS:
api-keysApiKeysAPI KeysAPI Keysrc/app/Validate:
apps/web/src/app/<route-path>/ must NOT already exist. If it does, warn and ask user to confirm.Before generating any code, read:
apps/web/src/lib/api-server.ts ← to get the correct server-side client function
apps/web/src/hooks/use-api-query.ts
apps/web/src/lib/constants.ts ← for PAGINATION_DEFAULTS and ROUTES
Critical: The server-side API client is accessed via getServerClient() from @/lib/api-server, NOT via a named apiServer export. Always call const client = await getServerClient({ auth: true }) in server components.
Create apps/web/src/app/<route-path>/page.tsx.
Pages are always React Server Components — never add "use client".
Base page (no flags):
import type { ReactElement } from "react";
import { <PascalName>Feature } from "@/components/features/<kebab-name>/<kebab-name>-feature";
export default function <PascalName>Page(): ReactElement {
return <<PascalName>Feature />;
}
If --auth is set:
import type { ReactElement } from "react";
import { redirect } from "next/navigation";
import { getServerClient } from "@/lib/api/server";
import { <PascalName>Feature } from "@/components/features/<kebab-name>/<kebab-name>-feature";
export default async function <PascalName>Page(): Promise<ReactElement> {
const client = await getServerClient({ auth: true });
const { data: user } = await client.api.users.me.get();
if (!user) {
redirect("/login");
}
return <<PascalName>Feature />;
}
Create apps/web/src/components/features/<kebab-name>/<kebab-name>-feature.tsx.
Feature components have "use client" and hold all interactive logic.
Base feature component (no flags):
"use client";
import type { ReactElement } from "react";
import { Box, Typography } from "@mui/material";
export function <PascalName>Feature(): ReactElement {
return (
<Box>
<Typography variant="h4" gutterBottom>
<Title Case Name>
</Typography>
{/* TODO: Add content */}
</Box>
);
}
If --list or --crud is set, generate a list view with search, table, and pagination controls:
"use client";
import type { ReactElement } from "react";
import { useState } from "react";
import {
Box,
Button,
CircularProgress,
InputAdornment,
Pagination,
Stack,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
TextField,
Typography,
} from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import { useApiQuery } from "@/hooks";
import { client } from "@/lib/api/client";
import { PAGINATION_DEFAULTS } from "@/lib/constants";
export function <PascalName>Feature(): ReactElement {
const [search, setSearch] = useState("");
const [page, setPage] = useState(PAGINATION_DEFAULTS.page);
const { data, isLoading } = useApiQuery(
["<kebab-name>", { page, search }],
() => client.api["<kebab-name>"].get({ query: { page, limit: PAGINATION_DEFAULTS.limit, search } }),
{ errorMessage: "Failed to load <title case>." },
);
const items = data?.items ?? [];
const totalPages = data?.pagination?.totalPages ?? 1;
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h4"><Title Case Name></Typography>
{/* [if --crud] <Button variant="contained" onClick={() => setCreateOpen(true)}>New <Singular Title></Button> */}
</Stack>
<TextField
placeholder="Search <title case>..."
size="small"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
sx={{ mb: 2 }}
/>
{isLoading ? (
<CircularProgress />
) : items.length === 0 ? (
<Typography color="text.secondary">No <title case> found.</Typography>
) : (
<>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
{/* TODO: Add domain-specific columns */}
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>{new Date(item.createdAt).toLocaleDateString()}</TableCell>
<TableCell align="right">
{/* TODO: Add action buttons */}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<Stack alignItems="center" mt={2}>
<Pagination
count={totalPages}
page={page}
onChange={(_, value) => setPage(value)}
color="primary"
/>
</Stack>
)}
</>
)}
</Box>
);
}
If --crud is set, also add dialog state at the top and the create dialog import:
const [createOpen, setCreateOpen] = useState(false);
// In JSX, add after the closing </Box>:
<Create<PascalName> Dialog open={createOpen} onClose={() => setCreateOpen(false)} />;
If --form is set (standalone form page, not --crud), generate:
"use client";
import type { ReactElement } from "react";
import { Box, Button, Stack, Typography } from "@mui/material";
import { useForm } from "@tanstack/react-form";
import { z } from "zod/v4";
import { FormTextField } from "@/components/ui/form";
import { useApiMutation } from "@/hooks";
import { client } from "@/lib/api/client";
const schema = z.object({
name: z.string().min(1, "Name is required"),
// TODO: Add fields matching the API schema
});
type FormValues = z.infer<typeof schema>;
export function <PascalName>Feature(): ReactElement {
const mutation = useApiMutation(
(data: FormValues) => client.api["<kebab-name>"].post(data),
{ successMessage: "<Singular Title> created.", invalidateKeys: [["<kebab-name>"]] },
);
const form = useForm({
defaultValues: { name: "" } as FormValues,
validators: { onSubmit: schema },
onSubmit: async ({ value }) => mutation.mutate(value),
});
return (
<Box maxWidth={480}>
<Typography variant="h4" gutterBottom>New <Singular Title></Typography>
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<Stack spacing={2}>
<form.Field name="name">
{(field) => <FormTextField field={field} label="Name" required />}
</form.Field>
{/* TODO: Add remaining form fields */}
<Button type="submit" variant="contained" loading={mutation.isPending}>
Create <Singular Title>
</Button>
</Stack>
</form>
</Box>
);
}
--crud)Create apps/web/src/components/features/<kebab-name>/create-<kebab-name>-dialog.tsx:
"use client";
import type { ReactElement } from "react";
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
} from "@mui/material";
import { useForm } from "@tanstack/react-form";
import { z } from "zod/v4";
import { FormTextField } from "@/components/ui/form";
import { useApiMutation } from "@/hooks";
import { client } from "@/lib/api/client";
interface Create<PascalName>DialogProps {
open: boolean;
onClose: () => void;
}
const schema = z.object({
name: z.string().min(1, "Name is required"),
// TODO: Add fields
});
type FormValues = z.infer<typeof schema>;
export function Create<PascalName>Dialog(props: Create<PascalName>DialogProps): ReactElement {
const { open, onClose } = props;
const mutation = useApiMutation(
(data: FormValues) => client.api["<kebab-name>"].post(data),
{
successMessage: "<Singular Title> created.",
invalidateKeys: [["<kebab-name>"]],
onSuccess: onClose,
},
);
const form = useForm({
defaultValues: { name: "" } as FormValues,
validators: { onSubmit: schema },
onSubmit: async ({ value }) => mutation.mutate(value),
});
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<DialogTitle>New <Singular Title></DialogTitle>
<DialogContent>
<Stack spacing={2} mt={1}>
<form.Field name="name">
{(field) => <FormTextField field={field} label="Name" required />}
</form.Field>
{/* TODO: Add remaining fields */}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button type="submit" variant="contained" loading={mutation.isPending}>
Create
</Button>
</DialogActions>
</form>
</Dialog>
);
}
Create apps/web/src/components/features/<kebab-name>/index.ts:
export * from "./<kebab-name>-feature";
// [if --crud] export * from "./create-<kebab-name>-dialog";
--auth)Add the new route to apps/web/src/lib/constants.ts under the ROUTES object:
<camelName>: "/<route-path>" as Route,
cd apps/web && bun run typecheck
Fix any errors before reporting. Common issues:
getServerClient (must come from @/lib/api-server)loading prop on Button requires @mui/lab or MUI v7+)"use client" on feature componentPage "<page-name>" scaffolded successfully!
Files created:
- apps/web/src/app/<route-path>/page.tsx
- apps/web/src/components/features/<kebab-name>/<kebab-name>-feature.tsx
[if --crud] - apps/web/src/components/features/<kebab-name>/create-<kebab-name>-dialog.tsx
- apps/web/src/components/features/<kebab-name>/index.ts
Files modified:
[if --auth] - apps/web/src/lib/constants.ts (added route)
Next steps:
1. Update the API route key in useApiQuery to match the exact endpoint path
2. [if --list/--crud] Add columns matching your data model fields
3. [if --crud] Fill in the form fields in the create dialog
4. Run: cd apps/web && bun run dev
apiServer export. v2 reads api-server.ts first and correctly uses getServerClient({ auth: true }).--crud flag: Generates a create dialog component alongside the list, wired to a "New X" button. v1 had a TODO comment; v2 scaffolds the full dialog.<Pagination> tied to the totalPages from the API response.PAGINATION_DEFAULTS: v1 hardcoded limit: 10. v2 imports from constants.ts for consistency.constants.ts automatically.bun run typecheck at the end and lists common failure modes."use client" to page.tsx or layout.tsx"use client" at the topimport { Box, Button } from "@mui/material" — never deep imports@/ path alias, never relative ../../ pathszod/v4, not zodpage.tsx, layout.tsx)useCallback, useMemo, or memo — React 19 compiler handles memoization