grit expose form / table — surfacing resources outside the admin
One command per resource emits a Next.js page wired to the shared schema + React Query hook.
Every Grit resource ships with an admin page automatically. But what if you want the SAME resource to appear on your customer-facing site? A contact form on /contact-us, a job listings table at /careers, a product catalogue at /shop? grit expose (shipped in v3.31.21) scaffolds those pages — one command, one file, fully wired to the resource's shared schema and React Query hook.
The two commands
# A form that creates new records$grit expose form Contact --to apps/web/app/contact-us/page.tsx# A paginated list of existing records$grit expose table Contact --to apps/web/app/contacts/page.tsx
Both commands:
- Read the resource's Go model from
apps/api/internal/models/<snake>.goto learn which fields exist. - Filter out framework columns (
id,version,*_at) and relationship associations (theGroup *Grouppointer that comes along withGroupID string). - Emit a single Next.js client page at the path you choose. Plain Tailwind — no admin chrome — so it fits a marketing site or a customer dashboard.
- Wire the page to the auto-generated React Query hook (
useCreate<Resource>for forms,use<Resources>for tables). - Refuse to overwrite an existing file unless you pass
--force— protects hand-customised pages from accidental loss.
Anatomy of the commands
grit expose form Contact --to apps/web/app/contact-us/page.tsx --force└──┬──┘ └──┬──┘ └─┬──┘ └─┬──┘ └────────────────┬───────────────┘ └──┬──┘│ │ │ │ │ ││ │ │ │ │ └── Optional. Overwrite an existing│ │ │ │ │ file at --to. Safety-off; useful│ │ │ │ │ in CI but ask before using locally.│ │ │ │ ││ │ │ │ └── Destination .tsx. Must end in .tsx. Parent dirs│ │ │ │ are created if missing. Convention: pick a path│ │ │ │ inside apps/web/app/... matching the URL you want.│ │ │ ││ │ │ └── Required flag. Distinguishes from positional args.│ │ ││ │ └── Resource name. PascalCase. Must match an existing model at│ │ apps/api/internal/models/contact.go (i.e. Contact must be│ │ generated already; the page is derived from its struct).│ ││ └── Subcommand. "form" emits a Create page; "table" emits a List page.│└── Top-level expose verb. Parent for both form + table.
What you get — form
For Contact (with fields name, email, phone), the emitted page looks like this (abridged):
"use client";import { useState } from "react";import { useForm } from "react-hook-form";import { useCreateContact } from "@/hooks/use-contacts";export default function ContactFormPage() {const [done, setDone] = useState(false);const { mutate: create, isPending, error: serverError } = useCreateContact();const { register, handleSubmit, reset } = useForm<Record<string, unknown>>();const onSubmit = (input: Record<string, unknown>) => {create(input, { onSuccess: () => { setDone(true); reset(); } });};if (done) return <SuccessCard />;return (<main className="flex min-h-screen items-center justify-center bg-slate-50 p-4"><form onSubmit={handleSubmit(onSubmit)} className="space-y-4 ..."><label>Name<input {...register("name")} /></label><label>Email<input {...register("email")} /></label><label>Phone<input {...register("phone")} /></label>{/* ... */}<button type="submit">{isPending ? "Sending…" : "Submit"}</button></form></main>);}
Field types map heuristically from the Go type:
string→<input type="text" />(or a textarea for long-text-shaped names)int / uint / float64→type="number"bool→type="checkbox"*time.Time→type="date"
What you get — table
"use client";import { useState } from "react";import { useContacts } from "@/hooks/use-contacts";export default function ContactTablePage() {const [search, setSearch] = useState("");const [page, setPage] = useState(1);const { data, isLoading } = useContacts({ search, page, pageSize: 20 });const rows = (data?.data ?? []) as unknown as Record<string, unknown>[];const pages = data?.meta?.pages ?? 1;return (<main className="mx-auto min-h-screen max-w-5xl bg-slate-50 p-4"><header className="flex items-center justify-between"><h1>Contacts</h1><input value={search} onChange={(e) => { setSearch(e.target.value); setPage(1); }} /></header><table><thead><tr><th>Name</th><th>Email</th><th>Phone</th></tr></thead><tbody>{rows.map((row, i) => (<tr key={(row.id as string) ?? i}><td>{String(row.name ?? "")}</td><td>{String(row.email ?? "")}</td><td>{String(row.phone ?? "")}</td></tr>))}</tbody></table>{/* prev / next pagination */}</main>);}
The table inherits the API's search + pagination — search hits the same auto-generated ?search= param the admin uses, pagination uses ?page= + ?page_size=.
Field filtering — what makes it into the page
Both commands run autoFields() against the parsed Go struct, dropping anything that can't render as a single primitive input or table cell:
| Filter | Why |
|---|---|
| ID, Version, CreatedAt, UpdatedAt, DeletedAt | Framework-owned. Visitors don't set these; the server does. |
Pointer / value associations (e.g. Group *Group) | Can't bind to one input. The FK column (GroupID string) still comes through. |
Slices (e.g. Tags []Tag) | Many-to-many; needs a multi-select widget, not in scope for v1. |
| Custom enum types, JSON columns | Not handled by the heuristic. Add the input by hand if needed. |
groupId) but the API expects snake_case (group_id) — they mismatch. The form submits snake-case keys directly and lets server-side validation handle errors. Add client-side validation by hand if you need it.Editing the generated page
Once written, the file is yours. The header comment says so:
// AUTO-GENERATED by `grit expose form Contact`. Safe to edit — this file// is not re-emitted by sync. Re-run grit expose form to overwrite.
Common follow-ups after expose:
- Wrap in your site's layout — replace the bare
<main>with your shared header / footer. - Replace the FK string input with a real dropdown — use the related resource's React Query hook to fetch options. For Contact's
group_id, that'suseGroups(). - Hide private fields — if your Contact model has an
internalNotesfield, you don't want it on a public form. Delete the input. (The API will accept submissions without it.) - Re-style with your brand — replace the slate-grey palette with your own colours. The default is deliberately neutral.
Working with custom paths
The --to path defines the URL. Some patterns that work well:
# Top-level marketing page--to apps/web/app/contact-us/page.tsx → /contact-us# Nested--to apps/web/app/products/new/page.tsx → /products/new# In a route group (no URL impact)--to apps/web/app/(marketing)/contact/page.tsx → /contact# Dynamic segment (less common — you'd typically embed in a parent layout instead)--to apps/web/app/forms/contact/page.tsx → /forms/contact
After generation, the CLI prints the URL it inferred:
✓ Wrote apps/web/app/contact-us/page.tsxNext steps:cd apps/web && pnpm devopen http://localhost:3000/contact-us
Quick check
Try it
In your contact-app, expose both the form AND the table for the Contact resource:
grit expose form Contact --to apps/web/app/contact-us/page.tsxgrit expose table Contact --to apps/web/app/contacts/page.tsxcd apps/web && pnpm dev(in a separate terminal if the dev server isn't running).- Visit
http://localhost:3000/contact-usand submit a contact named "Test User". - Visit
http://localhost:3000/contacts— "Test User" should be in the list. (You may need to be signed in — the auto-generated routes are auth-protected by default.)
Now try generating a custom field on top of the auto-generated form. Open the expose-emitted contact-us/page.tsx and add a hardcoded group_id value (use any group's UUID from your admin) so visitors don't have to type one.
Combining expose with form sharing
The previous lesson showed how to mint a public-link share for any resource. Combining that with grit expose form:
- Authenticated visitors — give them the expose-generated
/contact-uspage. Submits go through the regularuseCreateContact()hook, which means the request carries the auth cookie and runs under the visitor's identity (great for "create on behalf of yourself" flows). - Anonymous visitors — give them an expose-generated page with
--public-share(see below). Submits go through the FormShare dispatcher; no auth required.
--public-share: a public form on YOUR url
The default share lives at /forms/[token] on apps/web. That works but the URL looks like an admin artifact. With --public-share + --token you can scaffold a form at any URL of your choosing that posts to the same public endpoint:
$grit expose form Contact \$ --to apps/web/app/contact-us/page.tsx \$ --public-share \$ --token 9CkLh7gJZQrPeNwMo3F8x_iVjA8U2nXt
The emitted page:
- Has no dependency on the auth'd
useCreateContacthook — it imports axios directly and posts to/api/public/forms/<token>/submit. - On mount, fetches
/api/public/forms/<token>to confirm the link works + learn whether to render a password gate. - Shows a clear error UI when the token is missing or the share is disabled — visitors aren't left staring at a blank form.
- Hard-codes the token into the source. The operator can override per-environment by editing the constant.
Token from env instead of hard-coded
Drop the --token flag entirely and the emitted page reads NEXT_PUBLIC_FORM_TOKEN from the web app's .env at module load time. Useful when the token differs per environment (staging vs production):
$grit expose form Contact \$ --to apps/web/app/contact-us/page.tsx \$ --public-share# Then in apps/web/.env.local:$NEXT_PUBLIC_FORM_TOKEN=9CkLh7gJZ...# Or in your CI / deploy config:# staging: NEXT_PUBLIC_FORM_TOKEN=staging-share-token# production: NEXT_PUBLIC_FORM_TOKEN=prod-share-token
The CLI prints a heads-up when --token is omitted so you don't forget to set the env var.
--public-share with a hard-coded token is great for a single campaign or a public marketing page that always posts to the same share. The env-var path shines for multi-tenant or per-environment configurations. Skip --public-share entirely when the form should run under the visitor's own session (e.g. an account-settings flow).What's next
You can now move resources outside the admin. The final piece — what if those new web pages need to be auth-gated? Like an/account page that requires sign-in. The next lesson covers grit add web-auth: the middleware and wrapper component that close that gap.
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