Field types deep dive — slug, images, video, tags, defaults

The patterns you reach for once a plain string isn't enough — with cookbook recipes.

12 minmedium

Lesson 2 listed thirteen field types in a table. This lesson is the field-by-field deep dive — the patterns you reach for once a plain string isn't enough. By the end you can generate an Article with a URL slug, a cover image, a YouTube video, a gallery of photos, and a list of tags — and know exactly why each one was modelled the way it was.

slug — URL-friendly auto-IDs

A slug is a URL-safe identifier derived from another field. You want /blog/my-first-post, not /blog/c8f5a93b-2401-4a7e-9d11. slug gives you that without writing the slugifier yourself.

Auto-generated from the first string field

Terminal
$grit generate resource Article \
$ --fields "title:string,slug:slug,content:richtext"

When you create an Article with { title: "My First Post" }, the BeforeCreate hook fills in slug = "my-first-post-a8f3" — lowercased, non-alphanumerics replaced with hyphens, plus a 4-byte hex suffix so two posts with the same title don't collide.

Customise which field the slug comes from

Pass the source field as the third colon-separated part:

Terminal
$grit generate resource Product \
$ --fields "sku:string:unique,name:string,slug:slug:sku"

Now the slug is derived from sku instead of the first string column. (Without that hint the generator would have used sku anyway because it's the first string — but being explicit beats relying on field order.)

What the generated hook looks like

apps/api/internal/models/article.go (excerpt)
func (m *Article) BeforeCreate(tx *gorm.DB) error {
if m.ID == "" {
m.ID = uuid.New().String()
}
if m.Slug == "" {
m.Slug = slugify(fmt.Sprintf("%v", m.Title))
}
return nil
}

Two more facts worth remembering:

  • The slug column gets a unique index automatically — no :unique modifier needed.
  • Slug fields don't show up in the admin form (you can override by editing the resource page) — they're intended to be derived, not typed by hand.

Images, avatars, banners — string fields with smart sizing

Grit doesn't have an image field type. It has something better: it watches the name of a string field and upgrades the column to VARCHAR(500) automatically if it looks like a URL. So you just use :string and Grit handles the storage:

Terminal
$grit generate resource Article \
$ --fields "title:string,cover:string,thumbnail:string,avatar:string"

All four columns become VARCHAR(500) (signed S3 URLs and UTM-tagged image links eat 255 characters fast). The heuristic triggers on:

Exact match: url, image, avatar, thumbnail, logo, cover, icon, banner, photo
Suffix: anything_url (image_url, profile_url, callback_url, …)

How the upload flow works

The field stores the URL; the file itself lives on S3 (or MinIO / R2 / B2). Grit ships an upload handler at POST /api/uploads that:

  1. Receives a multipart file from the admin Form.
  2. Streams it to your bucket via the storage service.
  3. Returns { data: { url: "https://…/file.jpg" } }.

The admin FormBuilder spots fields named like images (image, avatar, cover, …) and renders a drag-and-drop uploader that POSTs to /api/uploads and writes the returned URL back into the form. Zero glue code.

Want the upload widget but the URL column is named differently (e.g., headshot)? Either rename it to a heuristic match (photo, avatar) or open the generated admin page and change the form field's type: "text" to type: "image" by hand.

Videos — same trick, different field name

Grit doesn't care if the URL points at a JPEG or an MP4. A video field is just a string column holding the URL:

Terminal
$grit generate resource Course \
$ --fields "title:string,video_url:string,duration_seconds:int"

The _url suffix triggers the VARCHAR(500) upgrade. The frontend decides what to do with the URL — embed a <video> tag, load it into a player, or treat it as a YouTube/Vimeo embed URL.

For uploaded video files, the same /api/uploads endpoint works — Grit's upload handler accepts any MIME type (and an env var caps the max size).

string_array — galleries and tag lists

Two-for-one: same type, two common uses depending on whether you're storing URLs or freeform strings.

Use 1: photo gallery / screenshot list

Terminal
$grit generate resource Listing \
$ --fields "title:string,description:text,photos:string_array"

The Go side becomes:

apps/api/internal/models/listing.go (excerpt)
type Listing struct {
ID string `gorm:"primarykey;size:36" json:"id"`
Title string `gorm:"size:255" json:"title"`
Description string `gorm:"type:text" json:"description"`
Photos datatypes.JSONSlice[string] `gorm:"type:json" json:"photos"`
// …
}

Stored as a single JSON column (["url1","url2","url3"]), not a separate table. The TS type is string[]. And — this is the nice bit — the admin form renders string_array with a multi-file image uploader out of the box. Drag in five photos, the form POSTs each to /api/uploads, and the URLs land in the array.

Use 2: freeform tag list

Terminal
$grit generate resource Post \
$ --fields "title:string,body:richtext,tags:string_array"

Same column type — just stores ["tutorial","go","react"] instead of URLs. The admin form's image uploader is appropriate for galleries; for freeform tags you'll usually swap the form field type to a chips-input in the generated page.tsx. (Or use the many_to_many relationship covered in the next lesson, which gets you a proper Tag table with its own list page.)

When to pick which: string_array when the values are strings the user types (tags, photo URLs, keywords) and you don't need to query/list them as their own entities. many_to_many:Tag when tags need their own admin page, slug, color, usage count — when a Tag is a thing.

text vs richtext — when format matters

Both store long text in a TEXT column. The difference is the editor:

  • text — plain textarea. Good for notes, internal-only descriptions, prompts you'll send to an LLM.
  • richtext — Tiptap Word-style editor with bold/italic/underline, headings, lists, links, images, tables. Good for blog posts, knowledge-base articles, marketing copy.
Terminal
$grit generate resource KnowledgeArticle \
$ --fields "title:string,slug:slug,summary:text,body:richtext"

Heuristic field names — free upgrades

Three name patterns that change column storage even though the type is plain string or float:

Pattern (on type)Column becomesExamples
URL-shaped (string)VARCHAR(500)avatar, logo, photo, banner, *_url
Long-text-shaped (string)TEXTdescription, notes, content, body, summary, bio, message
Money-shaped (float)DECIMAL(12,2)price, amount, total, *_cost, *_fee, *_salary, *_balance
Terminal
$grit generate resource Invoice \
$ --fields "number:string:unique,amount:float,description:string,due_date:date"

The generator quietly does the right thing for each column:

  • amount:floatDECIMAL(12,2) (the name amount triggers the money heuristic).
  • description:stringTEXT (long-text heuristic — even though you wrote string).
  • due_date:dateDATE column, not TIMESTAMP (use :datetime if you need the time component).

Defaults — when you need them, switch to YAML

Defaults are not available in the inline --fields string (intentional — keeps the syntax copy-pasteable). When you need a default, use a YAML definition:

task.yaml
name: Task
fields:
- name: title
type: string
required: true
- name: status
type: string
default: pending # GORM "default:pending" → DB-side default
- name: priority
type: int
default: 3
- name: archived
type: bool
default: false
Terminal
$grit generate resource Task --from task.yaml

Common-field cookbook

Five recipes you'll re-use across most resources:

# Blog post
title:string, slug:slug, excerpt:text, cover:string, body:richtext,
published:bool, published_at:datetime, tags:string_array
# Course / lesson
title:string, slug:slug, description:text, thumbnail:string,
video_url:string, duration_seconds:int, free_preview:bool
# E-commerce product
sku:string:unique, name:string, slug:slug:sku, description:richtext,
price:float, stock_quantity:int, photos:string_array, featured:bool
# Real-estate listing
title:string, slug:slug, address:string, city:string, state:string,
price:float, bedrooms:int, bathrooms:float, square_feet:int,
description:text, photos:string_array, listed_at:datetime
# Calendar event
title:string, location:string, starts_at:datetime, ends_at:datetime,
all_day:bool, notes:text, color:string

Quick check

You're building a /portfolio resource for showcasing client projects. Each project has a title, a hero image, a body of marketing copy with formatting, and 4–10 screenshots. Best field spec?

Try it

Design a Recipe resource for a cooking app. Fields it needs:

  • A title with a URL slug
  • A cover photo
  • Prep time in minutes (whole numbers)
  • A formatted body with steps (bold, lists, headings)
  • A list of 3–10 ingredient strings
  • A "published" toggle

Write the grit generate resource Recipe --fields "…" command and paste it into notes.md. Then actually run it on your project.

What's next

You can now generate single-table resources with rich fields. The next lesson is what makes Grit feel like a framework instead of a script: relationships. We'll build a Contact + Group pair where each contact belongs to a group and every group lists its contacts — using belongs_to and many_to_many.

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub