Using the generated API from the web app

The auto-generated React Query hook + shared Zod schemas — list, create, update, delete.

10 minmedium

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:

FileWhat it does
apps/web/hooks/use-contacts.tsReact Query hooks — useContacts, useContact, useCreateContact, useUpdateContact, useDeleteContact.
packages/shared/types/contact.tsTypeScript interface — what a Contact looks like coming back from the API.
packages/shared/schemas/contact.tsZod 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:

apps/web/app/contacts/page.tsx
"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 fetch call. The hook owns the axios client, the auth cookies, the React Query cache — you just call it.
  • Typed all the way through. c.name autocompletes, c.xyz errors. The type comes from @repo/shared/types via the hook.
  • Pagination shape is shared. data.data is the row array; data.meta has 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 (
<>
<input
value={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

apps/web/app/contacts/[id]/page.tsx
"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.

apps/web/app/contacts/new/page.tsx
"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>
<button
type="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>
);
}
Notice: zero hand-written types. 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
updateContact({ 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:

apps/web/hooks/use-contacts.ts (auto-generated)
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.go and move the GET routes 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

You added a `salutation` field to Contact in Go, ran `grit migrate` + `grit sync`. The web form using CreateContactSchema doesn't ask for it. What happened?

Try it

In your contact-app, build two pages in the web app that consume the generated Contact resource:

  1. apps/web/app/contacts/page.tsx — list all contacts, with a search box.
  2. apps/web/app/contacts/new/page.tsx — create form using CreateContactSchema from @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