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.

12 minmedium

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.

Terminal
# 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) or files.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 fields array 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

apps/api/internal/files/types.go (generated)
// 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"` // images
Height *int `json:"height,omitempty"` // images
Duration *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 uploads table 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. ThumbnailURL is 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:

apps/api/internal/files/lifecycle.go (sketch)
// 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. CleanupRemoved diffs 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.
Why reflection, not codegen. Every generated handler is identical at the lifecycle layer — 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.

apps/admin/resources/users.ts
{
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

apps/admin/lib/excel-utils.ts (the surface)
// 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.

  1. 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.
  2. 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.
  3. 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

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

  1. Scaffold the resource: grit generate resource Product title:string price:number hero:file:image.
  2. 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.
  3. 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.
  4. 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.)
  5. Delete a product: the lifecycle helper deletes the hero image from S3 in the same request.
Excel currently creates, not updates. v3.31.35's import POSTs each row, which means the API treats it as a new record. Update-by-ID via import is on the roadmap; until then, hand-edit the few rows you need to change through the admin form, or write a one-off script for batch updates.

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 job
apps/admin/lib/
ā”œā”€ā”€ excel-utils.ts ← SheetJS wrappers: export, import, template
apps/admin/components/tables/
ā”œā”€ā”€ export-menu.tsx ← Split button + format dropdown
ā”œā”€ā”€ import-modal.tsx ← Drop → preview → submit
ā”œā”€ā”€ table-toolbar.tsx ← Mounts both above
apps/admin/components/forms/fields/
ā”œā”€ā”€ file-field.tsx ← Single FileRef field
└── files-field.tsx ← FileRefs gallery field
apps/admin/components/ui/
└── dropzone.tsx ← The five-variant primitive both fields wrap

Quick check

A user uploads a hero image to a Product, saves, then changes the hero to a different image and saves again. What happens to the first image's S3 object?

Try it

Scaffold a small catalog and exercise the whole flow:

  1. grit generate resource Product title:string price:number hero:file:image gallery:files:image.
  2. 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.
  3. Pick one product. Upload a hero image. Save. Replace it with a different image. Save. Open MinIO console at localhost:9001 and confirm the bucket has only one hero, not two.
  4. Filter the list to "Last 7 days", then Export → Excel. Confirm only the 10 you created come down, not whatever was there before.
  5. 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