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.

11 minmedium

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

Terminal
# 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>.go to learn which fields exist.
  • Filter out framework columns (id, version, *_at) and relationship associations (the Group *Group pointer that comes along with GroupID 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):

apps/web/app/contact-us/page.tsx (excerpt)
"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 / float64type="number"
  • booltype="checkbox"
  • *time.Timetype="date"

What you get — table

apps/web/app/contacts/page.tsx (excerpt)
"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:

FilterWhy
ID, Version, CreatedAt, UpdatedAt, DeletedAtFramework-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 columnsNot handled by the heuristic. Add the input by hand if needed.
The form skips Zod validation. The shared schema uses camelCase (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's useGroups().
  • Hide private fields — if your Contact model has an internalNotes field, 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.tsx
Next steps:
cd apps/web && pnpm dev
open http://localhost:3000/contact-us

Quick check

You ran `grit expose form Contact --to apps/web/app/contact-us/page.tsx` and got an error: `resource "Contact" not found`. What's going on?

Try it

In your contact-app, expose both the form AND the table for the Contact resource:

  1. grit expose form Contact --to apps/web/app/contact-us/page.tsx
  2. grit expose table Contact --to apps/web/app/contacts/page.tsx
  3. cd apps/web && pnpm dev (in a separate terminal if the dev server isn't running).
  4. Visit http://localhost:3000/contact-us and submit a contact named "Test User".
  5. 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-us page. Submits go through the regular useCreateContact() 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:

Terminal
$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 useCreateContact hook — 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):

Terminal
$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.

When to use which: --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