Field types deep dive — slug, images, video, tags, defaults
The patterns you reach for once a plain string isn't enough — with cookbook recipes.
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
$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:
$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
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
slugcolumn gets a unique index automatically — no:uniquemodifier 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:
$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, photoSuffix: 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:
- Receives a multipart file from the admin Form.
- Streams it to your bucket via the storage service.
- 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.
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:
$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
$grit generate resource Listing \$ --fields "title:string,description:text,photos:string_array"
The Go side becomes:
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
$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.)
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.
$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 becomes | Examples |
|---|---|---|
| URL-shaped (string) | VARCHAR(500) | avatar, logo, photo, banner, *_url |
| Long-text-shaped (string) | TEXT | description, notes, content, body, summary, bio, message |
| Money-shaped (float) | DECIMAL(12,2) | price, amount, total, *_cost, *_fee, *_salary, *_balance |
$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:float→DECIMAL(12,2)(the nameamounttriggers the money heuristic).description:string→TEXT(long-text heuristic — even though you wrotestring).due_date:date→DATEcolumn, notTIMESTAMP(use:datetimeif 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:
name: Taskfields:- name: titletype: stringrequired: true- name: statustype: stringdefault: pending # GORM "default:pending" → DB-side default- name: prioritytype: intdefault: 3- name: archivedtype: booldefault: false
$grit generate resource Task --from task.yaml
Common-field cookbook
Five recipes you'll re-use across most resources:
# Blog posttitle:string, slug:slug, excerpt:text, cover:string, body:richtext,published:bool, published_at:datetime, tags:string_array# Course / lessontitle:string, slug:slug, description:text, thumbnail:string,video_url:string, duration_seconds:int, free_preview:bool# E-commerce productsku:string:unique, name:string, slug:slug:sku, description:richtext,price:float, stock_quantity:int, photos:string_array, featured:bool# Real-estate listingtitle: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 eventtitle:string, location:string, starts_at:datetime, ends_at:datetime,all_day:bool, notes:text, color:string
Quick check
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