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 fifteen 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.
File uploads (:file: and :files:) get their own lesson next because they touch S3, lifecycle, and Excel I/O — too much weight for a single section here. This lesson covers everything else.
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 — three ways to get them
Single images and image galleries each have a few options depending on how much metadata you want stored. The cleanest modern way is :file:image for one image and :files:image for a gallery — those get the richest UI and the full lifecycle from the next lesson. The older patterns still work and are worth knowing because most existing Grit projects use them.
The three patterns at a glance
| CLI spec | Column type | Admin form | When to use |
|---|---|---|---|
| cover:file:image | JSON FileRef | image dropzone (file variant) | Recommended — full metadata, automatic lifecycle |
| photos:files:image | JSON FileRef[] | multi-file gallery, drag-to-reorder | Recommended for galleries — same lifecycle as above |
| photos:string_array | JSON string[] | multi-image uploader (URLs only) | Legacy / simpler shape if you don't need mime+size on the row |
| cover:string | VARCHAR(500) | plain text input | Just a URL — paste an external CDN link, no upload UI |
The URL-shaped name heuristic
Even with the plain-string approach, Grit watches the name of a string field and bumps the column to VARCHAR(500) if it looks URL-shaped — signed S3 URLs and UTM-tagged tracking links blow past 255 fast.
$grit generate resource Article \$ --fields "title:string,cover:string,thumbnail:string,avatar:string"
All four columns become VARCHAR(500). 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
Whichever spec you pick, the file itself lives on S3 (or MinIO / R2 / B2) — the DB column just stores the reference. Grit ships an upload handler at POST /api/uploads that receives a multipart file, streams it to your bucket via the storage service, and returns a FileRef (or just { url } for legacy callers). The admin form does the round-trip automatically; you only see the result land in the field.
:image or :images type. Those exist on the admin TS side (you can set type: "image" by hand in a form field), but the CLI generator only ships :file / :files for typed uploads and :string_array for the legacy URL-list uploader. Pick from those three; the next lesson goes deep on the file types.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:file:image, body:richtext,published:bool, published_at:datetime, tags:string_array# Course / lessontitle:string, slug:slug, description:text, thumbnail:file:image,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, hero:file:image, photos:files:image,spec_sheet:file:pdf, 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:files:image, floorplan:file:pdf,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. Next we cover file fields in depth — :file: and :files: are richer than :image / :images because they carry FileRef metadata (mime + size + thumbnail), trigger automatic lifecycle cleanup on replace, and round-trip through Excel for bulk edits. After that: relationships with 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