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 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
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

View Scaffolding

Auto view generation

ID Async Select

Relational data selection

Search Input

Search component

Using Services

Service integration