Skip to content

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 one
products: field.hasMany("Product"), // the other side of that

belongsTo is the real thing: a column holding a foreign key. hasMany stores nothing — it is a way of looking at the other table.

The common case. A category has many products; a product has one category.

Terminal window
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:

Cloudflare — db/schema/products.ts
categoryId: text("category_id").references(() => categories.id, { onDelete: "set null" }),
Next.js — prisma/schema/resources.prisma
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:

resources/category.resource.ts
// 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.

category: field.belongsTo("Category", { required: false }), // set null
category: field.belongsTo("Category"), // cascade
category: 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.

A belongsTo with unique: true. Each row may claim at most one target.

Terminal window
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.

belongsTo("User") points at Better Auth’s account table — “who reported it”, “who approved it”, “who it is assigned to”:

Terminal window
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:

Terminal window
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.

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.

Terminal window
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/product.resource.ts
// 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() })) },
},
});

Tags on a post: no quantity, no price, nothing but the pair.

Terminal window
npx flare gen resource PostTag --fields 'post:belongsTo(Post), tag:belongsTo(Tag)'

Then stop the same pair being added twice, outside the generated block:

prisma/schema/base.prisma — or a hand-written migration
@@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.

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

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: { … },
});
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