Relationships
Generate resources with relationships — belongs_to for foreign keys and many_to_many for junction tables. The code generator handles the Go model, API handlers with eager loading, Zod schemas, TypeScript types, and admin form components automatically.
belongs_to
The belongs_to field type creates a foreign key relationship. When you add a belongs_to field to a resource, the code generator automatically creates:
- A foreign key column (
category_id) with a GORM index - A GORM association struct field with
foreignKeytag Preloadcalls in all handler queries for eager loading- A searchable relationship select dropdown in admin forms
- Dot notation column display in the DataTable
Syntax
You can either let the generator infer the related model from the field name, or specify it explicitly when the field name differs from the model:
category:belongs_to — infers the related model Category from the field name.
author:belongs_to:User — explicitly sets the related model to User, since "author" doesn't match a model name directly.
Generated Go Model
The code generator produces a Go struct with both the foreign key column and the association field:
type Product struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:255" json:"name" binding:"required"`
CategoryID uint `gorm:"index" json:"category_id" binding:"required"`
Category Category `gorm:"foreignKey:CategoryID" json:"category"`
Price float64 `json:"price"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}Handler with Preload
The generated handler uses GORM's Preload to automatically eager-load the related model in every query. This means the API response always includes the full related object, not just the foreign key ID:
// List with eager loading
db.Preload("Category").Find(&products)
// Get by ID
db.Preload("Category").First(&product, id)
// After create/update — reload to include related data in response
db.Preload("Category").First(&product, product.ID)Admin Form — Relationship Select
The form generates a relationship-select field that fetches options from the related resource's API endpoint:
{
key: "category_id",
label: "Category",
type: "relationship-select",
required: true,
relatedEndpoint: "/api/categories",
displayField: "name",
}- Auto-fetches all categories via React Query
- Searchable dropdown with loading state
- Displays the
namefield (configurable viadisplayField)
DataTable — Dot Notation
In the resource definition, the table column uses dot notation to display the related model's name:
{ key: "category.name", label: "Category", sortable: false }This accesses row.category.name from the API response, which includes the Preloaded data. Because the related data comes from a join, sorting on dot notation columns is disabled by default.
many_to_many
The many_to_many field type creates a junction table relationship. GORM handles the junction table automatically — you don't need to create or manage it yourself. The code generator produces the Go model annotation, association management in handlers, and a multi-select component in the admin form.
Syntax
For many_to_many, the related model is always required (unlike belongs_to where it can be inferred):
Generated Go Model
The many2many GORM tag tells GORM to create and manage the junction table automatically. The table name follows the convention model_field (e.g., product_tags):
type Product struct {
// ...other fields...
Tags []Tag `gorm:"many2many:product_tags" json:"tags"`
}Handler — Association Management
Many-to-many associations require special handling in create and update operations. The generated handler uses GORM's Association API to attach and replace related records by their IDs:
// Create — attach tags by IDs
if len(req.TagIDs) > 0 {
var tags []models.Tag
h.DB.Where("id IN ?", req.TagIDs).Find(&tags)
h.DB.Model(&item).Association("Tags").Replace(tags)
}
// Update — replace tags (pointer to detect omission)
if req.TagIDs != nil {
var tags []models.Tag
h.DB.Where("id IN ?", *req.TagIDs).Find(&tags)
h.DB.Model(&item).Association("Tags").Replace(tags)
}The Replace method removes any existing associations and replaces them with the new set. In the update handler, a pointer (*req.TagIDs) is used to distinguish between "not provided" (nil) and "explicitly set to empty" (empty slice), enabling partial updates.
Admin Form — Multi-Select
The form generates a multi-relationship-select field that allows selecting multiple related records:
{
key: "tag_ids",
label: "Tags",
type: "multi-relationship-select",
relatedEndpoint: "/api/tags",
displayField: "name",
relationshipKey: "tags",
}- Shows removable badge chips for selected items
- Searchable dropdown with multi-select support
relationshipKeymaps to the API response field for extracting existing selections in edit mode
has_one & has_many (Inverse Side)
has_one and has_many are the inverse of belongs_to. They don't need generator field types because:
- The foreign key lives on the child model (the one with
belongs_to) - When you generate
Productwithcategory:belongs_to, theCategorymodel automatically has many Products via GORM conventions - You can add the association manually to your parent model if you need to query from the parent side
// Add to your Category model manually
type Category struct {
// ...existing fields...
Products []Product `gorm:"foreignKey:CategoryID" json:"products,omitempty"`
}This is a manual step — the generator does not add inverse associations automatically, since not every parent model needs to query its children. Add the field when you need it, and GORM will handle the rest.
Full Example — E-Commerce
Here is a complete workflow that demonstrates both relationship types in an e-commerce scenario. Generate the parent models first, then the child model with relationships:
The Product resource definition generated by the commands above includes both relationship types in the columns and form fields:
export default defineResource({
name: "Product",
endpoint: "/api/products",
columns: [
{ key: "name", label: "Name", sortable: true, searchable: true },
{ key: "category.name", label: "Category", sortable: false },
{ key: "price", label: "Price", format: "number", sortable: true },
{ key: "published", label: "Published", format: "boolean" },
],
form: {
fields: [
{ key: "name", label: "Name", type: "text", required: true },
{
key: "category_id",
label: "Category",
type: "relationship-select",
required: true,
relatedEndpoint: "/api/categories",
displayField: "name",
},
{
key: "tag_ids",
label: "Tags",
type: "multi-relationship-select",
relatedEndpoint: "/api/tags",
displayField: "name",
relationshipKey: "tags",
},
{ key: "price", label: "Price", type: "number" },
{ key: "published", label: "Published", type: "toggle" },
],
},
})Customizing Relationships
The generated relationship configuration works out of the box, but you can customize it to fit your needs. Here are the most common adjustments:
displayField
Defaults to "name". Change it to display a different field in the dropdown and table. For example, if your related model uses title instead of name:
// Show user email instead of name
{
key: "author_id",
label: "Author",
type: "relationship-select",
relatedEndpoint: "/api/users",
displayField: "email",
}
// Show article title
{
key: "article_id",
label: "Article",
type: "relationship-select",
relatedEndpoint: "/api/articles",
displayField: "title",
}relatedEndpoint
Auto-generated as /api/<plural>. Change it if your API uses a different path or if you need to hit a filtered endpoint:
// Custom API path
{
key: "category_id",
label: "Category",
type: "relationship-select",
relatedEndpoint: "/api/v2/product-categories",
displayField: "name",
}
// Filtered endpoint — only active users
{
key: "assignee_id",
label: "Assignee",
type: "relationship-select",
relatedEndpoint: "/api/users?active=true",
displayField: "name",
}Table Display
The dot notation in column definitions (category.name) can be changed to access any nested field from the Preloaded response. For example, you might want to display a category's slug instead of its name:
columns: [
// Display category slug instead of name
{ key: "category.slug", label: "Category Slug", sortable: false },
// Display author email
{ key: "author.email", label: "Author Email", sortable: false },
]