Skip to main content
Learn how to customize components and views generated by Sonamu to fit your project requirements.

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:
Generation phase: Basic CRUD functionality, standard UI patterns

Customization phase: Business logic, unique UI/UX
When customization is needed:
  • Special validation rules
  • Complex relationship handling
  • Unique UI/UX requirements
  • Permission-based feature control
  • Workflow integration
Customization vs Regeneration:
  • 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>
  );
}
Customized (filters added):
// 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>
  );
}
Added features:
  • 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>
  );
}
Customized (complex validation):
// 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>
  );
}
Added features:
  • 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>
  );
}
Customized (sorting, selection, actions):
// 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>
  );
}
Added features:
  • 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
  }}
/>
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