Customization Overview
Post-generation Editing
Full controlFree modifications
Props Extension
Add new featuresInterface extension
Styling
Design systemTheme application
Logic Addition
Business logicValidation
Customization Philosophy
Sonamu’s Approach
Sonamu follows the “80% automatic, 20% customization” philosophy. Generation vs Customization:Copy
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):Copy
// 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>
);
}
Copy
// 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):Copy
// 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>
);
}
Copy
// 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):Copy
// 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>
);
}
Copy
// 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
Copy
// 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
Copy
// 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);
}
Copy
// components/users/UserTable.tsx
import styles from "./UserTable.module.css";
export function UserTable({ users }: UserTableProps) {
return (
<table className={styles.table}>
{/* ... */}
</table>
);
}
Styled Components
Copy
// 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]};
`;
Copy
// 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
Copy
<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
Copy
<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
Copy
<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
Copy
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
Copy
// 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
Copy
pnpm scaffold:view User
git add .
git commit -m "feat: scaffold User views"
# Now safe to customize
2. Common Logic as Hooks
Copy
// 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
Copy
/* styles/components.css */
.entity-table { }
.entity-form { }
.entity-detail { }
4. Extend Types
Copy
// 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:
- Regeneration overwrites - Git commit required
- Type changes should be done in backend first
- Separate complex logic into Hooks
- Make Props interfaces extensible
- Maintain style consistency