Admin Panel

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 foreignKey tag
  • Preload calls 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:

terminal
# Infer related model from field name
$ grit generate resource Product --fields "name:string,category:belongs_to,price:float"
# Explicit related model (when FK name differs)
$ grit generate resource Post --fields "title:string,author:belongs_to:User,content:text"

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:

apps/api/internal/models/product.go
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:

apps/api/internal/handlers/product_handler.go
// 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:

Relationship select field definition
{
  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 name field (configurable via displayField)

DataTable — Dot Notation

In the resource definition, the table column uses dot notation to display the related model's name:

Column definition with dot notation
{ 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):

terminal
$ grit generate resource Product --fields "name:string,category:belongs_to,tags:many_to_many:Tag,price:float"

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

apps/api/internal/models/product.go
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:

apps/api/internal/handlers/product_handler.go
// 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:

Multi-relationship select field definition
{
  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
  • relationshipKey maps 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 Product with category:belongs_to, the Category model 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
apps/api/internal/models/category.go
// 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:

terminal
# Step 1: Generate Category (the parent)
$ grit generate resource Category --fields "name:string,slug:slug,description:text"
# Step 2: Generate Tag
$ grit generate resource Tag --fields "name:string:unique"
# Step 3: Generate Product with relationships
$ grit generate resource Product --fields "name:string,category:belongs_to,tags:many_to_many:Tag,price:float,published:bool"

The Product resource definition generated by the commands above includes both relationship types in the columns and form fields:

apps/admin/resources/products.ts
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:

Custom displayField
// 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 relatedEndpoint
// 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:

Custom dot notation columns
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 },
]