Customization Overview
Post-generation Editing
Full control Free modifications
Props Extension
Add new features Interface extension
Styling
Design system Theme application
Logic Addition
Business logic Validation
Customization Philosophy
Sonamu’s Approach
Sonamu follows the “80% automatic, 20% customization” philosophy. Generation vs Customization:Generation phase: Basic CRUD functionality, standard UI patterns
↓
Customization phase: Business logic, unique UI/UX
- Special validation rules
- Complex relationship handling
- Unique UI/UX requirements
- Permission-based feature control
- Workflow integration
- Generated files are overwritten when regenerated
- Therefore, Git commit immediately after generation is recommended
- Customization involves directly modifying generated code
- If needed, create new components to use
View Customization
Extending List Page
Add features to generated list page. Original (auto-generated):// pages/users/index.tsx
export default function UsersPage() {
const [page, setPage] = useState(1);
const { users, total, isLoading } = useUsers({ page, pageSize: 20 });
return (
<div>
<h1>Users</h1>
<UserTable users={users} isLoading={isLoading} />
{/* Pagination */}
</div>
);
}
// pages/users/index.tsx (modified)
export default function UsersPage() {
const [page, setPage] = useState(1);
const [filters, setFilters] = useState({
role: null as string | null,
isActive: true,
search: "",
});
const { users, total, isLoading } = useUsers({
page,
pageSize: 20,
...filters, // Filters added
});
return (
<div>
<div className="header">
<h1>Users</h1>
<button onClick={() => router.push("/users/new")}>
Create New User
</button>
</div>
{/* Added: Filter UI */}
<div className="filters">
<SearchInput
value={filters.search}
onChange={(search) => setFilters({ ...filters, search })}
placeholder="Search users..."
/>
<select
value={filters.role || ""}
onChange={(e) => setFilters({ ...filters, role: e.target.value || null })}
>
<option value="">All Roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="guest">Guest</option>
</select>
<label>
<input
type="checkbox"
checked={filters.isActive}
onChange={(e) => setFilters({ ...filters, isActive: e.target.checked })}
/>
Active Only
</label>
</div>
<UserTable users={users} isLoading={isLoading} />
{/* Pagination */}
</div>
);
}
- Search filter
- Role filter
- Active status filter
Form Customization
Add business logic to create/edit forms. Original (auto-generated):// components/users/UserForm.tsx
export function UserForm({ initialValues, onSubmit, isSubmitting }: UserFormProps) {
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: initialValues,
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email", { required: true })} />
<input {...register("username", { required: true })} />
<button type="submit">Save</button>
</form>
);
}
// components/users/UserForm.tsx (modified)
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
// Validation schema
const userSchema = z.object({
email: z.string()
.email("Invalid email address")
.refine(
async (email) => {
// Check email duplication
const { exists } = await userService.checkEmail({ email });
return !exists;
},
{ message: "Email already taken" }
),
username: z.string()
.min(3, "Username must be at least 3 characters")
.max(20, "Username must be at most 20 characters")
.regex(/^[a-zA-Z0-9_]+$/, "Username can only contain letters, numbers, and underscores"),
password: z.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number"),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
export function UserForm({ initialValues, onSubmit, isSubmitting }: UserFormProps) {
const { register, handleSubmit, formState: { errors }, watch } = useForm({
resolver: zodResolver(userSchema),
defaultValues: initialValues,
});
const password = watch("password");
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Email *</label>
<input {...register("email")} />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<label>Username *</label>
<input {...register("username")} />
{errors.username && <span className="error">{errors.username.message}</span>}
</div>
<div>
<label>Password *</label>
<input type="password" {...register("password")} />
{errors.password && <span className="error">{errors.password.message}</span>}
{/* Added: Password strength indicator */}
{password && (
<div className="password-strength">
<div className="strength-bar" data-strength={getPasswordStrength(password)} />
<span>{getPasswordStrength(password)}</span>
</div>
)}
</div>
<div>
<label>Confirm Password *</label>
<input type="password" {...register("confirmPassword")} />
{errors.confirmPassword && <span className="error">{errors.confirmPassword.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</button>
</form>
);
}
- Zod schema-based validation
- Email duplication check (async)
- Password complexity rules
- Password confirmation
- Password strength indicator
Table Customization
Add features to tables. Original (auto-generated):// components/users/UserTable.tsx
export function UserTable({ users, isLoading }: UserTableProps) {
return (
<table>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Username</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>{user.username}</td>
</tr>
))}
</tbody>
</table>
);
}
// components/users/UserTable.tsx (modified)
interface UserTableProps {
users: User[];
isLoading: boolean;
onRowClick?: (user: User) => void;
onDelete?: (userId: number) => void;
selectable?: boolean;
onSelectionChange?: (selectedIds: number[]) => void;
}
export function UserTable({
users,
isLoading,
onRowClick,
onDelete,
selectable,
onSelectionChange
}: UserTableProps) {
const [sortField, setSortField] = useState<keyof User>("id");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// Sort logic
const sortedUsers = useMemo(() => {
return [...users].sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
const order = sortOrder === "asc" ? 1 : -1;
return aVal > bVal ? order : -order;
});
}, [users, sortField, sortOrder]);
// Sort handler
function handleSort(field: keyof User) {
if (sortField === field) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortOrder("asc");
}
}
// Selection handler
function handleSelect(userId: number) {
const newSelection = selectedIds.includes(userId)
? selectedIds.filter(id => id !== userId)
: [...selectedIds, userId];
setSelectedIds(newSelection);
onSelectionChange?.(newSelection);
}
function handleSelectAll() {
const newSelection = selectedIds.length === users.length
? []
: users.map(u => u.id);
setSelectedIds(newSelection);
onSelectionChange?.(newSelection);
}
if (isLoading) return <div>Loading...</div>;
return (
<table>
<thead>
<tr>
{selectable && (
<th>
<input
type="checkbox"
checked={selectedIds.length === users.length && users.length > 0}
onChange={handleSelectAll}
/>
</th>
)}
<th onClick={() => handleSort("id")} className="sortable">
ID {sortField === "id" && (sortOrder === "asc" ? "↑" : "↓")}
</th>
<th onClick={() => handleSort("email")} className="sortable">
Email {sortField === "email" && (sortOrder === "asc" ? "↑" : "↓")}
</th>
<th onClick={() => handleSort("username")} className="sortable">
Username {sortField === "username" && (sortOrder === "asc" ? "↑" : "↓")}
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{sortedUsers.map((user) => (
<tr key={user.id}>
{selectable && (
<td>
<input
type="checkbox"
checked={selectedIds.includes(user.id)}
onChange={() => handleSelect(user.id)}
/>
</td>
)}
<td onClick={() => onRowClick?.(user)}>{user.id}</td>
<td onClick={() => onRowClick?.(user)}>{user.email}</td>
<td onClick={() => onRowClick?.(user)}>{user.username}</td>
<td>
<button onClick={() => onRowClick?.(user)}>View</button>
<button onClick={() => onDelete?.(user.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
);
}
- Sort by column click
- Multiple selection with checkboxes
- Select all/deselect all
- Action buttons per row
Component Styling
Tailwind CSS
// components/users/UserTable.tsx
export function UserTable({ users }: UserTableProps) {
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Username
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.id}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.username}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
CSS Modules
// components/users/UserTable.module.css
.table {
width: 100%;
border-collapse: collapse;
}
.table thead {
background-color: var(--color-gray-100);
}
.table th {
padding: 12px;
text-align: left;
font-weight: 600;
}
.table td {
padding: 12px;
border-top: 1px solid var(--color-gray-200);
}
.table tbody tr:hover {
background-color: var(--color-gray-50);
}
// components/users/UserTable.tsx
import styles from "./UserTable.module.css";
export function UserTable({ users }: UserTableProps) {
return (
<table className={styles.table}>
{/* ... */}
</table>
);
}
Styled Components
// components/users/UserTable.styled.ts
import styled from "styled-components";
export const Table = styled.table`
width: 100%;
border-collapse: collapse;
`;
export const TableHead = styled.thead`
background-color: ${(props) => props.theme.colors.gray[100]};
`;
export const TableHeader = styled.th`
padding: 12px;
text-align: left;
font-weight: 600;
`;
export const TableRow = styled.tr`
&:hover {
background-color: ${(props) => props.theme.colors.gray[50]};
}
`;
export const TableCell = styled.td`
padding: 12px;
border-top: 1px solid ${(props) => props.theme.colors.gray[200]};
`;
// components/users/UserTable.tsx
import * as S from "./UserTable.styled";
export function UserTable({ users }: UserTableProps) {
return (
<S.Table>
<S.TableHead>
<S.TableRow>
<S.TableHeader>ID</S.TableHeader>
<S.TableHeader>Email</S.TableHeader>
<S.TableHeader>Username</S.TableHeader>
</S.TableRow>
</S.TableHead>
<tbody>
{users.map((user) => (
<S.TableRow key={user.id}>
<S.TableCell>{user.id}</S.TableCell>
<S.TableCell>{user.email}</S.TableCell>
<S.TableCell>{user.username}</S.TableCell>
</S.TableRow>
))}
</tbody>
</S.Table>
);
}
IdAsyncSelect Customization
Custom Option Rendering
<IdAsyncSelect
entity="User"
value={userId}
onChange={setUserId}
searchField="username"
// Custom option rendering
renderOption={(user) => (
<div className="user-option">
<img
src={user.avatar || "/default-avatar.png"}
alt=""
className="avatar"
/>
<div className="user-info">
<div className="username">{user.username}</div>
<div className="email">{user.email}</div>
{user.role && (
<span className="badge">{user.role}</span>
)}
</div>
</div>
)}
// Selected value rendering
renderValue={(user) => (
<div className="selected-user">
<img src={user.avatar || "/default-avatar.png"} alt="" />
<span>{user.username}</span>
</div>
)}
/>
Grouped Options
<IdAsyncSelect
entity="User"
value={userId}
onChange={setUserId}
searchField="username"
// Grouping function
groupBy={(user) => user.role}
// Group label
renderGroup={(role) => (
<div className="group-header">
{role.toUpperCase()}
</div>
)}
/>
SearchInput Customization
Custom Suggestion Rendering
<SearchInput
value={query}
onChange={setQuery}
suggestions={suggestions}
// Custom suggestion rendering
renderSuggestion={(suggestion) => (
<div className="suggestion-item">
<span className="icon">🔍</span>
<span className="text">{suggestion.text}</span>
{suggestion.count && (
<span className="count">{suggestion.count}</span>
)}
</div>
)}
onSelectSuggestion={(suggestion) => {
setQuery(suggestion.text);
// Execute search
}}
/>
Category-based Search
function CategorizedSearch() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("all");
return (
<div className="search-container">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="all">All</option>
<option value="posts">Posts</option>
<option value="users">Users</option>
<option value="comments">Comments</option>
</select>
<SearchInput
value={query}
onChange={setQuery}
placeholder={`Search ${category}...`}
onSearch={(q) => {
// Execute category-based search
searchService.search({
query: q,
category: category === "all" ? undefined : category,
});
}}
/>
</div>
);
}
Creating New Components
Create new components based on generated ones.Composite Component
// components/users/UserManagement.tsx
import { UserTable } from "./UserTable";
import { UserForm } from "./UserForm";
import { SearchInput } from "@/components/common/SearchInput";
export function UserManagement() {
const [mode, setMode] = useState<"list" | "create" | "edit">("list");
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [filters, setFilters] = useState({ search: "", role: null });
const { users, isLoading, refresh } = useUsers(filters);
async function handleCreate(data: CreateUserInput) {
await userService.create(data);
setMode("list");
refresh();
}
async function handleUpdate(data: UpdateUserInput) {
if (selectedUser) {
await userService.update(selectedUser.id, data);
setMode("list");
setSelectedUser(null);
refresh();
}
}
return (
<div className="user-management">
{mode === "list" && (
<>
<div className="header">
<h1>Users</h1>
<button onClick={() => setMode("create")}>
Create New User
</button>
</div>
<SearchInput
value={filters.search}
onChange={(search) => setFilters({ ...filters, search })}
placeholder="Search users..."
/>
<UserTable
users={users}
isLoading={isLoading}
onRowClick={(user) => {
setSelectedUser(user);
setMode("edit");
}}
/>
</>
)}
{mode === "create" && (
<div className="form-container">
<h2>Create New User</h2>
<UserForm onSubmit={handleCreate} />
<button onClick={() => setMode("list")}>Cancel</button>
</div>
)}
{mode === "edit" && selectedUser && (
<div className="form-container">
<h2>Edit User</h2>
<UserForm
initialValues={selectedUser}
onSubmit={handleUpdate}
/>
<button onClick={() => setMode("list")}>Cancel</button>
</div>
)}
</div>
);
}
Best Practices
1. Commit Immediately After Generation
pnpm scaffold:view User
git add .
git commit -m "feat: scaffold User views"
# Now safe to customize
2. Common Logic as Hooks
// hooks/useEntityCRUD.ts
export function useEntityCRUD<T>(entityName: string, service: any) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
async function create(data: Partial<T>) {
setSaving(true);
setError(null);
try {
const result = await service.create(data);
router.push(`/${entityName}/${result.id}`);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
return { create, saving, error };
}
3. Styles Globally
/* styles/components.css */
.entity-table {
}
.entity-form {
}
.entity-detail {
}
4. Extend Types
// types/user.types.ts (generated file)
export interface User {
id: number;
email: string;
username: string;
}
// types/user.extended.ts (custom file)
import type { User } from "./user.types";
export interface UserWithStats extends User {
postCount: number;
commentCount: number;
lastActiveAt: Date;
}
Cautions
Cautions when customizing: 1. Regeneration overwrites - Git commit required 2. Type
changes should be done in backend first 3. Separate complex logic into Hooks 4. Make Props
interfaces extensible 5. Maintain style consistency
Next Steps
View Scaffolding
Auto view generation
ID Async Select
Relational data selection
Search Input
Search component
Using Services
Service integration