File fields + Excel I/O
:file: / :files: syntax, FileRef lifecycle that stops bucket leaks, five dropzone variants, and the v3.31.35 client-side Excel import/export.
The previous lesson catalogued the generator's plain field types ā string, int, slug, image, tags, defaults. This lesson is about the two newer ones that pull more weight per token: :file: and :files:. They're the only field types that touch S3, the only ones with lifecycle rules, and ā as of v3.31.35 ā the only ones with a story for bulk edits through Excel.
Behind the scenes they wire into the storage battery (covered in chapter 6), but at the generator layer you don't see any of that. One token in the CLI gives you a typed column, an admin dropzone, a table preview, automatic cleanup on replace, an orphan-cleanup cron, and an import/export round- trip ā all without writing a line of upload code.
Why these are first-class field types
Treating files like a regular field (instead of a separate feature) removes a lot of boilerplate. Every resource that has a file column still needs:
- A model field that stores more than just a key (name, mime, size).
- A dropzone in the admin form.
- A preview cell in the table.
- Logic to delete the old file when the user picks a new one.
- Logic to delete orphan uploads if the form gets abandoned.
- A way to round-trip the column through Excel for bulk edits.
- A model field that stores more than just a key (name, mime, size).
- A dropzone in the admin form.
- A preview cell in the table.
- Logic to delete the old file when the user picks a new one.
- Logic to delete orphan uploads if the form gets abandoned.
- A way to round-trip the column through Excel for bulk edits.
Writing that six times across six resources is the work this layer removes.
:file: and :files: in the resource generator
The CLI parses two new field-type tokens. :file: means one uploaded file; :files: means a gallery. Both take an optional accept-list as the third colon part ā either a single alias (:file:image) or a bracketed list (:files:[pdf,doc,image]). Valid aliases are image, video, audio, pdf, doc, excel, csv, zip, archive, and all. Omit the third part and it defaults to all.
# A Product with a single hero image and a gallery of PDFs+docs.$grit generate resource Product \$ --fields "title:string,hero:file:image,spec_sheets:files:[pdf,doc]"
Three things happen for each file token:
- The Go model gets a
*files.FileRef(single) orfiles.FileRefs(multi) column stored as JSON. - The Zod schema and TypeScript type emit the matching shape, so the admin form picks up the right field component automatically.
- The resource definition's
fieldsarray gets a{ type: "file" | "files", accepts: [...], maxSizeMB: N }entry ā that's what the FileField/FilesField components read.
The CLI sets a default maxSizeMB per field: 300 if the accept list contains video, 5 otherwise. You can override it by hand in the resource definition.
FileRef ā the JSON shape
// FileRef is the canonical JSON shape stored in a resource's// file column. The frontend uploads to /api/uploads and gets// back this exact shape, which it then submits to the parent// resource's create or update endpoint. Storing the metadata// inline avoids an N+1 join against the uploads table on every// list page render.type FileRef struct {URL string `json:"url"`Key string `json:"key"`Name string `json:"name"`MIME string `json:"mime"`Size int64 `json:"size"`Width *int `json:"width,omitempty"` // imagesHeight *int `json:"height,omitempty"` // imagesDuration *int `json:"duration,omitempty"` // video/audio (sec)ThumbnailURL string `json:"thumbnail_url,omitempty"`}// FileRefs is a slice of FileRef with custom Value/Scan so// GORM stores it as JSON without a serializer tag.type FileRefs []FileRef
Why a struct, not just a key? Three reasons:
- Rendering without a join. The table cell needs the MIME to decide between an image thumbnail and a generic file icon. Storing it inline avoids hitting the
uploadstable on every row. - Layout-shift prevention. Width and Height let the admin list reserve the right amount of space before the image loads ā same trick a CDN-rendered image card uses.
- Cheap previews.
ThumbnailURLis set when the upload pipeline generates a thumbnail (currently for images); table cells prefer it over the full-resolution URL.
The lifecycle ā what happens on save and delete
v3.31.30-33 closed the loop on file lifecycle. Three helpers in internal/files handle the cases that used to leak objects:
// Called inside the resource handler's Update path. Diffs the// old struct against the new one, finds every *FileRef and// FileRefs field via reflection, and S3-deletes any keys that// were dropped. One line in the handler regardless of how many// file columns the resource has.files.CleanupRemoved(ctx, storage, oldProduct, newProduct)// Called after Create + Update. Stamps claimed_at = now() on// every Upload row referenced by the model's FileRef columns,// so the orphan-cleanup cron knows these uploads are in use.files.ClaimRefs(ctx, db, product)// Daily cron job. Deletes Upload rows (and their S3 objects)// where claimed_at IS NULL AND created_at < now() - 24h.files.RunOrphanCleanup(ctx, db, storage, 24*time.Hour)
Concretely, the three leaks this closes:
- Replace. User picks file A, saves, then picks file B and saves again. Without
CleanupRemoved, file A stays in the bucket forever ā the DB row points at B but A is unreachable. - Gallery prune. User uploads four images, then removes the third in the dropzone.
CleanupRemoveddiffs the slice and deletes only the dropped key. - Abandoned form. User uploads a file, closes the tab without saving. The Upload row exists; no model references it. The cron picks it up 24h later.
CleanupRemoved(ctx, st, old, new). The reflection walk happens once per save, and the cost is well under the S3 round trip. Codegen would make every resource handler 30 lines longer for no real win.The dropzone ā five visual variants
The admin form picks a FileField or FilesField based on the resource definition. Both ship with five dropzone variants you can pick per field, so the same upload primitive renders as a banner uploader on the create form and a tiny avatar puck on the profile page.
{key: 'avatar',type: 'file',label: 'Profile photo',accepts: ['image'],maxSizeMB: 2,// Visual variant ā same data shape, different chrome.// "default" ā boxed dashed (forms)// "compact" ā single inline row (modals)// "minimal" ā small text link (inline edit)// "avatar" ā circular target (profile pages)// "inline" ā tag-style with mini progress (lists)dropzone: 'avatar',progress: 'circular', // 'bar' | 'circular' | 'pulse'}
Five variants because file upload is one of the UI affordances that has to look right in three or four different contexts. Forcing every form to use the same big dashed box looks amateurish; reskinning the same component each time is a waste.
v3.31.35 ā Excel + CSV + JSON, in the browser
Every resource list page now has a split Export button (default format on click, chevron opens the menu) and an Import button next to it. All three formats ā CSV, Excel (.xlsx), JSON ā are written in the browser via SheetJS. No new API routes; no excelize on the server; no async "your file is ready" email when the dataset is big.
Why client-side
The earlier v3.31.35 plan put export and import on the server, with an asynq + Resend cutoff for sheets over 5,000 rows. Doing it in the browser collapses all of that:
- No new endpoints. Export reuses the existing paginated list route; import reuses the existing POST route.
- No async wiring. The browser does the work synchronously; large sheets show a progress bar instead of firing a job + email.
- Tenant data stays in the session. Rows don't round-trip through a server-side renderer just to build a file.
Trade-off: very large datasets (~50k+ rows of wide records) are gated by browser memory, not server RAM. For the dataset sizes most admin panels deal with, it's fine ā and the export loop streams every page from the API before building the file, so the output represents the entire filtered dataset, not just what's on screen.
Export ā the menu
// Project rows onto the visible columns, write the file.exportToFile(rows, columns, 'products', 'xlsx')// Loop the resource endpoint until every row is in hand. The// search params come from the active filter / sort / date range,// so the export honours the user's current view.const rows = await fetchAllPages('/api/products',apiSearchParams,(loaded, total) => setProgress({ loaded, total }),)
The export menu lives on the toolbar of every resource page. Default click triggers the resource's default format (Excel when enabled, else CSV); the chevron opens the menu for the other formats. Hidden columns are excluded from the file, so what you see is what you get.
Import ā three stages
The Import button opens a modal with three stages.
- Pick. Drag-and-drop or click to browse. A "Download template" link generates a blank workbook keyed by the resource's field names plus an example row.
- Preview. SheetJS parses the file in-browser, maps headers to fields (case + space + underscore + hyphen insensitive), coerces each cell to the right JS type via the field definition, and surfaces per-row errors. Unknown header columns are flagged so users notice a typo before submitting.
- Submit. Each valid row is POSTed to the resource endpoint at concurrency 4 with a live progress bar. React Query invalidates on success so the list refreshes. Failed rows get a per-row reason in the summary screen.
Header matching is loose
Real spreadsheets come from finance and marketing, not engineers. Headers like First Name, first_name, firstname, and First-Name all map to the same field ā Grit normalises whitespace, underscores, hyphens, and case before lookup. The template you download is keyed by the wire field name, but the import accepts anything that round-trips through that normaliser.
Per-resource opt-outs
export const productsResource = defineResource({// ...table: {columns: [/* ... */],// Hide a format from the export menu (default: all on).export: { csv: true, excel: true, json: false },// Or disable export entirely:// export: false,// Restrict importable fields to a subset (defaults to every// form field). Useful when you want to exclude generated// columns or fields that need server-side calculation.import: { fields: ['title', 'price', 'stock'] },// Or disable import entirely:// import: false,},})
File fields (:file: / :files:) are automatically excluded from imports ā a spreadsheet can't carry a binary blob, so it makes no sense to accept them through this path. Users upload files the normal way and pair them with metadata via the import.
Putting it together ā a product catalog flow
The typical end-to-end shape this layer enables:
- Scaffold the resource:
grit generate resource Product title:string price:number hero:file:image. - Bulk-create from a spreadsheet: click Import, drop a 500-row .xlsx with title and price columns, confirm validation, submit. 500 products land in seconds.
- Add hero images one by one: open each product, drop the hero image into the avatar-variant dropzone, save. The S3 battery handles the upload; the lifecycle helper claims the upload row.
- Edit a batch in Excel: filter the list to "last 30 days", click Export ā Excel. Edit prices in the spreadsheet. Re-import via a separate sheet, or update individual rows. (Update via import is on the roadmap; for now imports create new rows.)
- Delete a product: the lifecycle helper deletes the hero image from S3 in the same request.
Files in this layer ā for when you need to dig
apps/api/internal/files/āāā types.go ā FileRef, FileRefsāāā lifecycle.go ā CleanupRemoved, ClaimRefs, RunOrphanCleanupāāā orphan_cleanup.go ā The daily cron jobapps/admin/lib/āāā excel-utils.ts ā SheetJS wrappers: export, import, templateapps/admin/components/tables/āāā export-menu.tsx ā Split button + format dropdownāāā import-modal.tsx ā Drop ā preview ā submitāāā table-toolbar.tsx ā Mounts both aboveapps/admin/components/forms/fields/āāā file-field.tsx ā Single FileRef fieldāāā files-field.tsx ā FileRefs gallery fieldapps/admin/components/ui/āāā dropzone.tsx ā The five-variant primitive both fields wrap
Quick check
Try it
Scaffold a small catalog and exercise the whole flow:
grit generate resource Product title:string price:number hero:file:image gallery:files:image.- In the admin, click Import, download the template, fill in 10 rows of titles and prices, drop it back, submit. Confirm 10 products land in the list.
- Pick one product. Upload a hero image. Save. Replace it with a different image. Save. Open MinIO console at
localhost:9001and confirm the bucket has only one hero, not two. - Filter the list to "Last 7 days", then Export ā Excel. Confirm only the 10 you created come down, not whatever was there before.
- On the Product resource, set
table: { export: { json: false } }and confirm the JSON entry disappears from the menu.
What's next
Next lesson ā Relationships. Once you have files attached to a Product, the next thing you reach for is belongs_to (an OrderItem belongs to an Order) andmany_to_many (a Product has many Tags). Same one-token short-form pattern, different cardinality.
Coming later in the framework (v3.31.36): PDF export via @react-pdf/renderer, which lets the same Export menu offer styled PDF receipts and reports alongside Excel.
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