A profile field is any variable associated with a user profile (age, politics, diet, orientation, etc.).
Choose the right storage type before starting:
| Kind | DB type | Example fields |
|---|---|---|
| Single-select choice | TEXT |
gender, mbti, education_level |
| Multi-select choices | TEXT[] |
diet, religion, orientation |
| Free text | TEXT |
political_details, gender_details |
| Numeric | INTEGER / NUMERIC |
age, drinks_per_month |
Array fields need a GIN index; scalar choice/text fields use btree.
For choice fields, add a choices constant and its inverse:
export const MY_CHOICES = {
'Label A': 'value_a',
'Label B': 'value_b',
} as const
export const INVERTED_MY_CHOICES = invert(MY_CHOICES)
export type MyChoiceTuple = {
[K in keyof typeof MY_CHOICES]: [K, (typeof MY_CHOICES)[K]]
}[keyof typeof MY_CHOICES]"Show more" pattern — when the list is long and a few choices cover most cases (such as man/woman for gender), export defaults and extras so the UI can collapse the uncommon ones:
export const DEFAULT_MY_CHOICES = [MY_CHOICES.ValueA, MY_CHOICES.ValueB]
export const EXTRA_MY_CHOICES = Object.values(MY_CHOICES).filter(
(v) => !DEFAULT_MY_CHOICES.includes(v as any),
)For gender, the defaults live in common/src/gender.ts (DEFAULT_GENDERS, EXTRA_GENDERS).
Add the column to the Row, Insert, and Update blocks of the profiles table (around line 1115):
// Row:
my_field: string | null // scalar
my_field: string[] | null // array
my_field_details: string | null // free-text companion
// Insert / Update (same but optional):
my_field ? : string | nullCreate backend/supabase/migrations/YYYYMMDD_add_<field>.sql:
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS my_field TEXT, -- scalar
ADD COLUMN IF NOT EXISTS my_field TEXT[], -- array
ADD COLUMN IF NOT EXISTS my_field_details TEXT;
-- free-text companion
-- Array fields need GIN; scalar choice/text fields use btree:
CREATE INDEX IF NOT EXISTS profiles_my_field_gin ON profiles USING GIN (my_field);
CREATE INDEX IF NOT EXISTS idx_profiles_my_field ON profiles USING btree (my_field);Run it (user runs this, not Claude):
yarn test:db:resetAdd to optionalProfilesSchema:
my_field: z.string().optional().nullable(), // scalar
my_field
:
z.array(z.string()).optional().nullable(), // array
my_field_details
:
z.string().optional().nullable(), // free-textAdd a filter WHERE clause. The column is already selected automatically (see step 2).
Scalar choice:
my_field?.length && where(`my_field = ANY($(my_field))`, {my_field}),Array (allow profiles with no value set):
my_field?.length &&
where(`my_field IS NULL OR my_field = '{}' OR my_field && $(my_field)`, {my_field}),Keyword search — add to textFields (free text) or arrayChoiceFields / choiceFields (choices):
// free text:
const textFields = [..., 'my_field_details'
]
// single-select choice:
const choiceFields = [..., {field: 'my_field', choices: MY_CHOICES}
]
// multi-select choice:
const arrayChoiceFields = [..., {field: 'my_field', choices: MY_CHOICES}
]Also add my_field?: string[] to profileQueryType.
Add to the get-profiles props:
my_field: arraybeSchema.optional(), // array
my_field
:
z.string().optional(), // scalarAdd to the FilterFields Pick and set a default in initialFilters:
// in FilterFields Pick:
|
'my_field'
// in initialFilters:
my_field: undefined,Add a branch in the array-value formatter so filter chips display human-readable labels:
} else
if (key === 'my_field') {
value = value.map(
(s) => translate(`profile.my_field.${s}`, INVERTED_MY_CHOICES[s] ?? s),
)
}Also add my_field: '' to filterLabels.
Follow the pattern of orientation-filter.tsx:
MyFieldFilterText— renders the chip summary ("Any X", specific labels, or "Multiple")MyFieldFilter— renders<MultiCheckbox>with optional "show more" button
"Show more" pattern:
const [showAll, setShowAll] = useState(
selected.some((v) => !DEFAULT_MY_CHOICES.includes(v)) ||
EXTRA_MY_CHOICES.includes(profile?.my_field as any),
)
const visibleChoices = Object.fromEntries(
Object.entries(MY_CHOICES).filter(
([, v]) => showAll || DEFAULT_MY_CHOICES.includes(v) || selected.includes(v),
),
)
// ...
{
!showAll && (
<button type="button" onClick={() => setShowAll(true)}>
{t('filter.my_field.show_more', 'Show more options')}
</button>
)
}Import and add a <FilterSection> for the new filter. Keep imports sorted alphabetically.
Add the field to the profile edit form. Free-text companion fields use <Input placeholder={t(...)} />.
"Show more" in the form uses the same pattern as the filter (§9) but reads profile.my_field to decide whether to
expand by default.
Add a display component following existing patterns (e.g., Religion, Orientation):
function MyField(props: {profile: Profile}) {
const {profile} = props
const t = useT()
const values = (profile as any).my_field as string[] | null | undefined
const details = (profile as any).my_field_details as string | null | undefined
if (!values?.length && !details) return null
const text = values
?.map((v) => t(`profile.my_field.${v}`, INVERTED_MY_CHOICES[v] ?? v))
.join(', ')
return (
<Col>
<span className="font-semibold">{t('profile.my_field_label', 'My Field')}</span>
{text && <span>{text}</span>}
{details && <span className="text-ink-600 italic">"{details}"</span>}
</Col>
)
}If the field reflects the viewer's own profile data (like diet, religion), add it to yourFilters and
isYourFilters so the "My Filters" preset works:
// yourFilters:
my_field: you?.my_field?.length ? you.my_field : undefined,
// isYourFilters:
isEqual(new Set(filters.my_field), new Set(you.my_field)) &&Skip this step if the field isn't part of the viewer's profile (e.g., a filter-only concept).
Add the field to UserAccountInformationForSeeding with realistic faker values so E2E seeds work.
Add keys to common/messages/fr.json and common/messages/de.json (alphabetically sorted). Required key groups for a
typical choice field:
| Key pattern | English fallback | Notes |
|---|---|---|
filter.any_my_field |
'Any X' |
chip when nothing selected |
filter.my_field.show_more |
'Show more options' |
show-more button in filter |
profile.my_field |
'X' |
section heading in profile-about |
profile.my_field.<value> |
label | one per choice value |
profile.my_field.details_placeholder |
'Details about…' |
form input placeholder |
profile.my_field.show_more |
'Show more options' |
show-more button in form |
profile.optional.my_field |
'X' |
form section label |
Translation key naming: values use the DB value (e.g., gray-asexual, not gray_asexual). The toKey() helper in
common/src/parsing.ts converts spaces to underscores and lowercases — the translationPrefix prop on
<MultiCheckbox> uses this automatically.
| File | Scalar choice | Array choices | Free text only |
|---|---|---|---|
choices.ts |
✓ | ✓ | — |
schema.ts |
✓ | ✓ | ✓ |
| Migration SQL | ✓ | ✓ | ✓ |
zod-types.ts |
✓ | ✓ | ✓ |
get-profiles.ts (filter) |
✓ | ✓ | — |
get-profiles.ts (search) |
✓ (choiceFields) | ✓ (arrayChoiceFields) | ✓ (textFields) |
api/schema.ts |
✓ | ✓ | — |
filters.ts |
✓ | ✓ | — |
filters-format.ts |
✓ | ✓ | — |
*-filter.tsx (new) |
✓ | ✓ | — |
filters.tsx |
✓ | ✓ | — |
optional-profile-form.tsx |
✓ | ✓ | ✓ |
profile-about.tsx |
✓ | ✓ | ✓ |
use-filters.ts |
if self-referential | if self-referential | — |
userInformation.ts |
✓ | ✓ | ✓ |
| Translation JSONs | ✓ | ✓ | ✓ |