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, useContact, 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, page_size: 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 { useContact } from "@/hooks/use-contacts";export default function ContactDetailPage({ params }: { params: Promise<{ id: string }> }) {const { id } = use(params);const { data, isLoading, error } = useContact(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();// UpdateupdateContact({ id: "01HX…", input: { name: "New name" } });// Delete (asks the API to soft-delete — soft delete is the default in Grit)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-client";import type { Contact } from "@repo/shared/types";const KEY = ["contacts"] as const;type ListParams = { page?: number; page_size?: number; search?: string };type ListResponse = { data: Contact[]; meta: { total: number; page: number; page_size: number; pages: number } };export function useContacts(params?: ListParams) {return useQuery({queryKey: [...KEY, params],queryFn: async () => {const { data } = await apiClient.get<ListResponse>("/api/contacts", { params });return data;},});}export function useContact(id: string) {return useQuery({queryKey: [...KEY, id],queryFn: async () => {const { data } = await apiClient.get<{ data: Contact }>("/api/contacts/" + id);return data.data;},enabled: Boolean(id),});}export function useCreateContact() {const qc = useQueryClient();return useMutation({mutationFn: async (input: Partial<Contact>) => {const { data } = await apiClient.post<{ data: Contact }>("/api/contacts", input);return data.data;},onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),});}export function useUpdateContact() {const qc = useQueryClient();return useMutation({mutationFn: async ({ id, input }: { id: string; input: Partial<Contact> }) => {const { data } = await apiClient.put<{ data: Contact }>("/api/contacts/" + id, input);return data.data;},onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),});}export function useDeleteContact() {const qc = useQueryClient();return useMutation({mutationFn: async (id: string) => {await apiClient.delete("/api/contacts/" + id);},onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),});}
Wait — what about auth?
The customer-facing web app might run public pages too. The generated routes default to middleware.Auth(...) — they require a logged-in user. Two ways to expose a resource publicly:
- Public read, gated write. Edit
apps/api/internal/routes/routes.goand move theGETroutes out of the auth-protected group while keeping POST/PUT/DELETE inside it. - Different resource entirely. Generate a public version that filters server-side (e.g. only
is_public = true) and keep the auth'd version for staff.
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).
You've finished Chapter 4
Eight lessons in. 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 rest of Grit assumes this fluency — every lesson from here builds on top of resources.
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