Using the generated API from the web app
The auto-generated React Query hook + shared Zod schemas — list, create, update, delete.
The admin panel is the operator side. The customer-facing web app (apps/web) is the other consumer of your generated API. This lesson shows you how to use the auto-generated React Query hook, the shared types, and the shared Zod schemas to build a list page and a create form in the web app without duplicating any types or contracts.
What the generator gave you for the web
For every grit generate resource Contact …, the web side gets three files dropped into apps/web:
| File | What it does |
|---|---|
| apps/web/hooks/use-contacts.ts | React Query hooks — useContacts (list), useGetContact (one), useCreateContact, useUpdateContact, useDeleteContact. |
| packages/shared/types/contact.ts | TypeScript interface — what a Contact looks like coming back from the API. |
| packages/shared/schemas/contact.ts | Zod schemas — CreateContactSchema, UpdateContactSchema. Use them in forms and at API boundaries. |
All three are importable from the web app. Same source of truth as the admin and the Go API.
1. Listing contacts on a customer page
The minimum useful list — paginated, with a loading state and an empty state:
"use client";import { useContacts } from "@/hooks/use-contacts";export default function ContactsPage() {const { data, isLoading, error } = useContacts({ page: 1 });if (isLoading) return <p>Loading…</p>;if (error) return <p className="text-red-500">Failed to load.</p>;if (!data || data.data.length === 0) return <p>No contacts yet.</p>;return (<ul className="space-y-2">{data.data.map((c) => (<li key={c.id} className="rounded-lg border p-3"><p className="font-medium">{c.name}</p><p className="text-sm text-gray-500">{c.email}</p></li>))}</ul>);}
Three things to notice:
- No
fetchcall. The hook owns the axios client, the auth cookies, the React Query cache — you just call it. - Typed all the way through.
c.nameautocompletes,c.xyzerrors. The type comes from@repo/shared/typesvia the hook. - Pagination shape is shared.
data.datais the row array;data.metahas total/page/page_size/pages — same shape every endpoint returns.
2. Searching + paginating
The hook accepts the same query-string params the API does:
"use client";import { useState } from "react";import { useContacts } from "@/hooks/use-contacts";export default function ContactsPage() {const [search, setSearch] = useState("");const [page, setPage] = useState(1);const { data, isLoading } = useContacts({ search, page, pageSize: 20 });return (<><inputvalue={search}onChange={(e) => { setSearch(e.target.value); setPage(1); }}placeholder="Search by name or email…"className="w-full rounded-lg border px-3 py-2"/>{isLoading ? (<p>Loading…</p>) : (<ul>{data?.data.map((c) => <li key={c.id}>{c.name}</li>)}</ul>)}<div className="mt-4 flex gap-2"><button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>Prev</button><span>Page {page} of {data?.meta.pages ?? 1}</span><button onClick={() => setPage((p) => p + 1)} disabled={page >= (data?.meta.pages ?? 1)}>Next</button></div></>);}
3. Loading a single contact
"use client";import { use } from "react";import { useGetContact } from "@/hooks/use-contacts";export default function ContactDetailPage({ params }: { params: Promise<{ id: string }> }) {const { id } = use(params);const { data, isLoading, error } = useGetContact(id);if (isLoading) return <p>Loading…</p>;if (error || !data) return <p>Not found.</p>;return (<article><h1>{data.name}</h1><p>{data.email}</p><p>{data.phone}</p></article>);}
4. Creating a contact from a public form
The shared Zod schema becomes the form's validator. One source of truth — change a field requirement in Go, regenerate with grit sync, and the form's validation catches up automatically.
"use client";import { useRouter } from "next/navigation";import { useForm } from "react-hook-form";import { zodResolver } from "@hookform/resolvers/zod";import { CreateContactSchema, type CreateContactInput } from "@repo/shared/schemas";import { useCreateContact } from "@/hooks/use-contacts";export default function NewContactPage() {const router = useRouter();const { mutate: createContact, isPending, error: apiError } = useCreateContact();const {register,handleSubmit,formState: { errors },} = useForm<CreateContactInput>({resolver: zodResolver(CreateContactSchema),});const onSubmit = (input: CreateContactInput) => {createContact(input, {onSuccess: (created) => router.push("/contacts/" + created.id),});};return (<form onSubmit={handleSubmit(onSubmit)} className="space-y-4"><label><span>Name</span><input {...register("name")} className="block w-full rounded-lg border px-3 py-2" />{errors.name && <p className="text-red-500 text-sm">{errors.name.message}</p>}</label><label><span>Email</span><input type="email" {...register("email")} className="block w-full rounded-lg border px-3 py-2" />{errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}</label><label><span>Phone</span><input {...register("phone")} className="block w-full rounded-lg border px-3 py-2" /></label><buttontype="submit"disabled={isPending}className="rounded-lg bg-accent px-4 py-2 text-white disabled:opacity-50">{isPending ? "Creating…" : "Create contact"}</button>{apiError && (<p className="text-red-500">{(apiError as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message ?? "Something went wrong"}</p>)}</form>);}
CreateContactInput and the validator both come from @repo/shared/schemas. If you add a field to Go and run grit sync, the form starts demanding it. No manual TS drift.5. Updating + deleting
import { useUpdateContact, useDeleteContact } from "@/hooks/use-contacts";const { mutate: updateContact } = useUpdateContact();const { mutate: deleteContact } = useDeleteContact();// Update — id is one key in the same flat object as the input fields.updateContact({ id: "01HX…", name: "New name" });// Delete (soft-delete via deleted_at — Grit's default for every model).deleteContact("01HX…");
Both mutations call queryClient.invalidateQueries({ queryKey: ['contacts'] }) on success — so any list pages currently rendered re-fetch and repaint. No manual cache management needed.
The auto-generated hook file in full
Curious what's actually inside apps/web/hooks/use-contacts.ts? Roughly this:
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { apiClient } from "@/lib/api";interface Contact {id: string;name: string;email: string;phone: string;created_at: string;updated_at: string;}interface ContactsResponse {data: Contact[];meta: { total: number; page: number; page_size: number; pages: number };}interface UseContactsParams {page?: number;pageSize?: number;search?: string;sortBy?: string;sortOrder?: string;}export function useContacts({page = 1,pageSize = 20,search = "",sortBy = "created_at",sortOrder = "desc",}: UseContactsParams = {}) {return useQuery<ContactsResponse>({queryKey: ["contacts", { page, pageSize, search, sortBy, sortOrder }],queryFn: async () => {const params = new URLSearchParams({page: String(page),page_size: String(pageSize),sort_by: sortBy,sort_order: sortOrder,});if (search) params.set("search", search);const { data } = await apiClient.get(`/api/contacts?${params}`);return data;},});}export function useGetContact(id: string) {return useQuery<Contact>({queryKey: ["contacts", id],queryFn: async () => {const { data } = await apiClient.get(`/api/contacts/${id}`);return data.data;},enabled: !!id,});}export function useCreateContact() {const queryClient = useQueryClient();return useMutation({mutationFn: async (input: Record<string, unknown>) => {const { data } = await apiClient.post("/api/contacts", input);return data;},onSuccess: () => {queryClient.invalidateQueries({ queryKey: ["contacts"] });},});}export function useUpdateContact() {const queryClient = useQueryClient();return useMutation({mutationFn: async ({ id, ...input }: { id: string } & Record<string, unknown>) => {const { data } = await apiClient.put(`/api/contacts/${id}`, input);return data;},onSuccess: () => {queryClient.invalidateQueries({ queryKey: ["contacts"] });},});}export function useDeleteContact() {const queryClient = useQueryClient();return useMutation({mutationFn: async (id: string) => {await apiClient.delete(`/api/contacts/${id}`);},onSuccess: () => {queryClient.invalidateQueries({ queryKey: ["contacts"] });},});}
Wait — what about auth?
Everything above breaks on day one of a real customer site. The generator wires every CRUD route into the protected group -- middleware.Auth(...) is mounted on it, so the web app gets a 401 Unauthorized the moment the operator logs out. For pages that should be public (a product list, a blog feed, anything an anonymous visitor reads), you have to move those endpoints out of protected.
Open apps/api/internal/routes/routes.go. The scaffold has three route groups:
r.Group("/api")with no middleware -- public. Use this for read-only endpoints anonymous visitors should reach.protected := r.Group("/api")withmiddleware.Auth(...)-- logged-in customer. Use for “my orders”, “my profile”, etc.admin := r.Group("/api")withmiddleware.Auth(...)+middleware.RequireRole("ADMIN")-- staff only. Use for write operations customers shouldn't do.
For a typical catalog (Categories + Products), the split is almost always:
- Public:
GET /categories,GET /categories/:id,GET /products,GET /products/:id-- anyone can browse. - Admin:
POST/PUT/PATCH/DELETE /categories,POST/PUT/PATCH/DELETE /products-- staff only.
The mechanical edit looks like this:
protected := r.Group("/api")protected.Use(middleware.Auth(db, authService)){// Generated routes: every CRUD operation behind auth.protected.GET("/products", productHandler.List)protected.GET("/products/:id", productHandler.GetByID)protected.POST("/products", productHandler.Create)protected.PUT("/products/:id", productHandler.Update)protected.PATCH("/products/:id", productHandler.Patch)// grit:routes:protected}
// PUBLIC: anyone can browse the catalog. No auth, no CSRF.public := r.Group("/api"){public.GET("/products", productHandler.List)public.GET("/products/:id", productHandler.GetByID)}protected := r.Group("/api")protected.Use(middleware.Auth(db, authService)){// Stays behind auth: who you are matters for these.protected.GET("/orders", orderHandler.List)protected.POST("/orders", orderHandler.Create)// grit:routes:protected}// Already exists in the scaffold. Move writes here so customers// can't bypass admin checks by hitting POST /api/products directly.admin := r.Group("/api")admin.Use(middleware.Auth(db, authService))admin.Use(middleware.RequireRole("ADMIN")){admin.POST("/products", productHandler.Create)admin.PUT("/products/:id", productHandler.Update)admin.PATCH("/products/:id", productHandler.Patch)admin.DELETE("/products/:id", productHandler.Delete)// grit:routes:admin}
handlers are already registered for path. Each method+path lives in exactly one group. Cut from the old group, paste into the new one.Once the writes live in the admin group, the admin app keeps working (its axios client sends the grit_access cookie, the ADMIN role check passes), but a malicious anonymous POST /api/products gets a 401 instead of silently creating a row.
For the customer web app, the next lesson (Public Catalog Cheatsheet — Category & Product) walks through every endpoint a typical catalog needs -- list, detail, by-category, related products -- with the exact handler / service / route / React-Query-hook code for each one.
Quick check
Try it
In your contact-app, build two pages in the web app that consume the generated Contact resource:
apps/web/app/contacts/page.tsx— list all contacts, with a search box.apps/web/app/contacts/new/page.tsx— create form usingCreateContactSchemafrom@repo/shared/schemas.
Open http://localhost:3000/contacts, create a contact via the form, and confirm it appears in the list (and in the admin panel — both apps share the API).
What's next
You can now generate, sync, customise, and remove resources, model relationships across all three cardinalities, pick between short and long form, and consume the generated API from both the admin and the customer web app. The last three lessons in this chapter cover going public — how to expose a resource's table or form outside the admin, how to give someone a token-gated public link to one resource without making the whole route open, and how to protect customer web pages with auth.
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