Relationships
Flare has one relationship primitive, belongsTo, and one view of it,
hasMany. Every shape is built from those two.
category: field.belongsTo("Category", { required: false }), // this row points at oneproducts: field.hasMany("Product"), // the other side of thatbelongsTo is the real thing: a column holding a foreign key. hasMany
stores nothing — it is a way of looking at the other table.
One to many
Section titled “One to many”The common case. A category has many products; a product has one category.
npx flare gen resource Category --fields 'name:string!, slug:string!'npx flare gen resource Product --fields 'name:string, price:money, category:belongsTo(Category)?'Write it on the many side. category:belongsTo(Category) goes on
Product, because the product is what holds the key.
The schema:
categoryId: text("category_id").references(() => categories.id, { onDelete: "set null" }),categoryId String? @map("category_id")category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)Prisma refuses one-sided relations, so the generator adds the other half to
Category automatically — named after the model: products Product[].
In the form: the product form gets a searchable picker. It shows the
target’s title field (name here, or whatever titleField says), searches as
you type, and stores the id. An optional relation gets a “None” entry.
In the table: the column shows the category’s name, not its id, with a link to it. Flare resolves the titles in one query for the whole page, not one per row.
To see the children, add the view to the other side, outside the generated block:
// generated:end products: field.hasMany("Product"),A category’s record page now has a table of its products under the fields.
Nothing is added to the database — hasMany is a query.
What happens on delete
Section titled “What happens on delete”category: field.belongsTo("Category", { required: false }), // set nullcategory: field.belongsTo("Category"), // cascadecategory: field.belongsTo("Category", { required: false, onDelete: "restrict" }),set null |
Default when optional. Delete the category; products keep going, uncategorised |
cascade |
Default when required. Delete the order; its lines go too |
restrict |
Refuse to delete while children exist. The API answers 409 |
Choose deliberately. cascade on the wrong relation deletes more than anyone
expected; restrict gives a clear error instead.
One to one
Section titled “One to one”A belongsTo with unique: true. Each row may claim at most one target.
npx flare gen resource Membership --fields 'tier:enum(free,pro)?, member:belongsTo(Member)!'The ! makes the foreign key unique, so two memberships cannot point at the
same member — that is the whole difference between one-to-one and
one-to-many.
A relation to the signed-in account
Section titled “A relation to the signed-in account”belongsTo("User") points at Better Auth’s account table — “who reported it”,
“who approved it”, “who it is assigned to”:
npx flare gen resource Report --fields 'title:string, reporterId:belongsTo(User)?, assigneeId:belongsTo(User)?'User is the one target that is not a Flare resource. Better Auth owns that
table, and generating a User resource to point at would make a second,
competing one — so Flare points the foreign key at the auth table directly:
user from db/auth-schema.ts on Cloudflare, the User model on Next.js. You
get the real foreign key, the index, and the relation picker, the same as any
other belongsTo.
onDelete defaults differently here. An optional one is set null: somebody
who closes their account should not be held open by a lost-property report from
two years ago, and the report usually wants to outlive them. A required one is
restrict, because there is nothing else it could be — a required column cannot
be set to null, and quietly deleting the record instead would destroy data
nobody asked to lose. Say onDelete in the descriptor to choose otherwise.
Who the picker can see. A list of everyone’s names and email addresses is
not something to hand to anybody who can edit a record, so the generated
/api/users endpoint is not a user directory:
| Who is asking | What they get |
|---|---|
An admin (ADMIN_ROLES) |
The searchable list — what reassigning a ticket needs |
| Anyone else signed in | Their own account, and nothing else |
| Signed out | 401 |
So anybody can set the field to themselves, and anybody can read back the name
of whoever is on a record they can already see; only an admin can enumerate the
table. The endpoint is generated only for apps that have a belongsTo("User")
field, so one that does not use the feature does not grow it.
hasMany("User") is not supported and says so: the relation goes on the
other side, as a belongsTo("User") field on the resource that belongs to a
user.
For ownership, a plain column is still often right. An ownership policy
names a field, and userId:string is enough for it:
npx flare gen resource Profile --fields 'bio:text?, website:website?, userId:string!'See roles and policies. The difference is the
admin: belongsTo("User") gives you a picker and a name, a plain column gives
you a uuid in a text box. For “who owns this” the answer is the person signed
in rather than one chosen from a list, so the column costs you nothing — for
“who is this assigned to”, the picker is the point.
userId String @unique @map("user_id")user User @relation(fields: [userId], references: [id], onDelete: Cascade)In the form: the same picker. The difference shows up on save — a second
profile for the same user is a 409:
{ "error": "User is already taken.", "field": "userId" }Which side holds the key? The one that is optional. A user can exist
without a profile, so Profile holds the key and User does not have to know
profiles exist.
Many to many
Section titled “Many to many”There is no manyToMany field, and that is deliberate: a join table almost
always grows columns. “Which products are in this order” becomes “how many, at
what price”. Make the join a resource of its own.
npx flare gen resource Order --fields 'reference:string!, total:money'npx flare gen resource Product --fields 'name:string, price:money'npx flare gen resource OrderItem --fields 'quantity:int, unitPrice:money, order:belongsTo(Order), product:belongsTo(Product)?'OrderItem is the join, and it carries the two things a plain join table
could not: quantity and the price at the time of sale.
Add the views to both sides:
// resources/order.resource.ts (outside the generated block)orderItems: field.hasMany("OrderItem"),
orderItems: field.hasMany("OrderItem"),In the form: you do not pick many products on the order form. You add lines — each with its own quantity and price — on the order’s record page, which is how anyone would describe the job out loud.
Writing both at once, in one statement:
await db.order.create({ data: { reference: "ORD-1042", total: 96.5, updatedAt: new Date(), orderItems: { create: lines.map((line) => ({ ...line, updatedAt: new Date() })) }, },});When the join really has nothing to say
Section titled “When the join really has nothing to say”Tags on a post: no quantity, no price, nothing but the pair.
npx flare gen resource PostTag --fields 'post:belongsTo(Post), tag:belongsTo(Tag)'Then stop the same pair being added twice, outside the generated block:
@@unique([postId, tagId])If the tags are a short fixed list and you do not need to query them as rows,
a multiselect is simpler and has no join at all:
tags: field.multiselect(["new", "sale", "clearance"], { required: false }),Use a join resource when tags are data people manage; use multiselect when
they are a fixed vocabulary.
Self-referencing
Section titled “Self-referencing”A category with sub-categories, or an employee with a manager:
parent: field.belongsTo("Category", { required: false, onDelete: "set null" }),Prisma needs both sides named when a model points at itself. The generator handles it; the result looks like:
parent Category? @relation("CategoryParent", fields: [parentId], references: [id])children Category[] @relation("CategoryParent")Choosing the picker’s label
Section titled “Choosing the picker’s label”The picker shows the target’s titleField, which defaults to the first string
field. Set it when that is wrong:
export default defineResource({ name: "Invoice", titleField: "number", // not the first string field, which might be a note fields: { … },});A quick reference
Section titled “A quick reference”| You want | Write | Where |
|---|---|---|
| One category, many products | category:belongsTo(Category)? |
On Product |
| See a category’s products | products: field.hasMany("Product") |
On Category |
| Who reported this | reporterId:belongsTo(User)? |
On the resource |
| One profile per account | userId:belongsTo(User)!, or userId:string! for ownership only |
On Profile |
| Products in many orders | An OrderItem resource with two belongsTo |
Its own resource |
| A fixed set of tags | tags:multiselect(a,b,c)? |
No relation at all |
| Sub-categories | parent:belongsTo(Category)? |
On itself |