Frontend

Fetching data in the web app

You create products in the admin panel. Strangers read them on the home page and on a product page, with no account and no sign-in. This is the whole path, with a real Product resource: what --public writes for you, where the API key comes from, and the two pages that render it.

Two surfaces, two opposite defaults

A Grit project has two frontends and they want contradictory things from the same table. The admin panel is for people who have signed in and are allowed to see everything, so its default is to show every column. The web app is for the internet, so its default has to be to show nothing until somebody says otherwise.

That is why the hooks the generator writes into apps/web/hooks/use-products.ts are not the answer for a storefront. They call the authenticated routes, which answer 401 without a session. Correct for a signed-in page, useless for a shop window.

The public surface is a separate thing: read-only endpoints, mounted outside auth, guarded by a publishable API key, serving an allowlist rather than the model.

Step 1: generate the resource with --public

Terminal
$grit generate resource Product \
$ --fields "name:string,slug:slug[name],description:text,price:money,image:file[image],featured:bool,stock:uint" \
$ --public --faker --count 24

--public adds the two read endpoints. --faker writes a seeder that fills twenty-four believable rows, which matters more than it sounds: an empty table hides every layout problem you have.

Among the usual output, two files are the ones this page is about:

✓ apps/api/internal/handlers/product_public.go the allowlist, yours to edit
✓ apps/web/lib/products-public.ts the typed read client
Held back: stock
Add any of those to the publicProduct struct in that file to publish them.

Read that last line. stock is in your model and is not in the public response, because the generator publishes the fields a storefront usually wants and holds back the ones that are usually nobody's business. It says which, so the decision is yours and not a surprise.

The allowlist is a struct, not a config

apps/api/internal/handlers/product_public.go holds what an anonymous caller may see:

// publicProduct is what an anonymous caller is allowed to see.
//
// An allowlist rather than the model. A column added next month is private
// until somebody adds it here.
type publicProduct struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
Price money.Money `json:"price"`
Image *files.FileRef `json:"image"`
Featured bool `json:"featured"`
}

Regenerating the resource does not overwrite this file. That is deliberate: it is the one file in the set where losing your edits means publishing a column you had decided to keep private.

Step 2: the API key, which the seeder gives you

The public endpoints are open to the internet but not unauthenticated. They want a publishable key, and grit seed mints one and writes it where the web app will find it:

Terminal
$grit migrate
$grit seed
================================================================
API keys
Publishable grit_pk_2b5f1f00_5ecea557f19...
Safe in a browser or a mobile app. Reaches
endpoints marked public, and nothing else.
Secret grit_sk_0e3679b6_9813a6c295c...
Server side only. Shown once, right now.
================================================================
Wrote ../web/.env.local
Wrote ../admin/.env.local
products: seeded 24 rows in 31ms (781 rows/s), now 24

apps/web/.env.local now holds the three values the read client uses:

NEXT_PUBLIC_API_KEY=grit_pk_2b5f1f00_5ecea557f19...
NEXT_PUBLIC_API_URL=http://localhost:8080
NEXT_PUBLIC_STORAGE_URL=http://localhost:8080

NEXT_PUBLIC_ means the browser can read it, which is correct here and would be a leak for the secret key. The publishable key is publishable by design: it reaches the read-only public routes and nothing else. The secret key never belongs in a frontend environment file.

You can see the difference the key makes with two requests:

$ curl "localhost:8080/api/v1/public/products?page_size=2"
{"error":{"code":"API_KEY_REQUIRED","message":"Send your publishable key as X-API-Key"}}
$ curl -H "X-API-Key: $KEY" "localhost:8080/api/v1/public/products?page_size=2"
{"data":[{"id":"01a1c553-...","name":"Terrific Skateboard","slug":"terrific-skateboard-97334142",
"price":{"amount":3674,"currency":"USD"},"featured":true, ...}],
"meta":{"total":24,"page":1,"page_size":2,"pages":12}}

Step 3: create a product in the admin

Nothing to build. The generator already wrote the admin screens, so pnpm dev and the panel has Products in its sidebar with a working table and create form. The fields come from the ones you declared: a money input with a currency picker for price, a dropzone for image, a switch for featured, and slug filled in from the name and read-only.

Create one, set featured, and it is on the public endpoint within the revalidation window. There is no publish step and no second system: the admin writes the row, the public endpoint reads the row.

What the read client gives you

apps/web/lib/products-public.ts was generated against your allowlist, so it is typed to exactly what the endpoint returns. Two functions:

FunctionReturnsFor
getPublicProducts(params){ data, meta }A list page, a grid, a featured row
getPublicProduct(key)PublicProduct | nullOne product, by id or slug

Three things it does that are worth knowing before you use it:

  • It sends the key on every request, and resolves the API address from API_INTERNAL_URL first, so a container can reach the API on the Docker network while the browser uses the public URL.
  • It is wrapped in React's cache(), so calling it twice in one render, which is exactly what a page and its generateMetadata do, is one request.
  • An unreachable API renders an empty list rather than failing the build. That is the state a production build is in while it runs in CI with no API beside it.

getPublicProduct is deliberately not symmetrical with that: a missing row is null, and an API that answers 500 throws. A broken backend is not a missing product and must not render as one.

Step 4: a featured row on the home page

A server component. No hook, no loading state, no useEffect: it awaits the data and ships HTML with the products already in it, which is also what a crawler reads.

// apps/web/components/featured-products.tsx
import Link from "next/link";
import { formatMoney } from "@repo/shared/types";
import { getPublicProducts } from "@/lib/products-public";
export async function FeaturedProducts() {
const { data: products } = await getPublicProducts({
featured: "true",
sort_by: "name",
sort_order: "asc",
page_size: 3,
});
if (products.length === 0) return null;
return (
<section className="mx-auto max-w-5xl px-6 py-16">
<div className="mb-8 flex items-end justify-between">
<h2 className="text-2xl font-bold tracking-tight">Featured</h2>
<Link href="/products" className="text-sm text-accent hover:underline">
All products
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<Link
key={product.id}
href={`/products/${product.slug}`}
className="group overflow-hidden rounded-xl border border-border bg-bg-elevated hover:border-accent/40"
>
<div className="h-48 overflow-hidden bg-bg-hover">
{product.image ? (
<img src={product.image.url} alt={product.name} loading="lazy"
className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" />
) : null}
</div>
<div className="p-5">
<h3 className="font-semibold text-foreground group-hover:text-accent">
{product.name}
</h3>
<p className="mt-3 font-mono text-sm text-foreground">
{formatMoney(product.price)}
</p>
</div>
</Link>
))}
</div>
</section>
);
}

Then drop it into the page:

// apps/web/app/(marketing)/page.tsx
import { FeaturedProducts } from "@/components/featured-products";
// ...inside the page, after the hero
<FeaturedProducts />

The scaffolded home page already sets export const revalidate = 60, so an edit in the admin shows up within a minute with no deploy.

Note the palette classes: bg-bg-elevated, text-text-secondary, text-accent. apps/web has its own token names, not the admin's muted and card. An unknown Tailwind utility renders nothing at all and says nothing about it, so copying a class list across from the admin is a silent way to lose your styling.

Step 5: the list page, with search and paging

Everything the list endpoint accepts is a query parameter, and searchParams is where they come from. A plain <form> with method=get is enough for search: no state, no client component, and the result is a URL somebody can share.

// apps/web/app/(marketing)/products/page.tsx
const PAGE_SIZE = 9;
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ page?: string; search?: string }>;
}) {
const params = await searchParams;
const requested = Number(params.page);
const page = Number.isInteger(requested) && requested > 0 ? requested : 1;
const search = params.search ?? "";
const { data: products, meta } = await getPublicProducts({
page,
page_size: PAGE_SIZE,
search: search || undefined,
});
const pages = meta?.pages ?? 0;
return (
<div className="mx-auto max-w-5xl px-6 py-16">
<h1 className="text-4xl font-bold tracking-tight">Products</h1>
<p className="mt-2 text-text-secondary">{meta?.total ?? 0} in the catalogue.</p>
<form action="/products" className="my-10">
<input type="search" name="search" defaultValue={search}
placeholder="Search products"
className="w-full rounded-lg border border-border bg-bg-elevated px-4 py-2.5" />
</form>
{/* the grid, then: */}
{pages > 1 ? <span>Page {page} of {pages}</span> : null}
</div>
);
}

Number.isInteger(requested) && requested > 0 rather than Number(params.page) || 1 because ?page=abc and ?page=-3 are both things a crawler will send you.

What the public list accepts

ParameterExample
Paging?page=2&page_size=9
Search?search=skateboard
Sort?sort_by=name&sort_order=asc
Filter, published columns only?featured=true
Numeric window?price_min=1000&price_max=5000

A parameter the handler does not allow is ignored rather than refused. That is the right behaviour for a surface crawlers and link shorteners append things to, and it is also why the filter lists in the handler are worth reading before you rely on one: archived_at is deliberately absent, because a column you can filter on is a column a stranger can set, and ?archived=false would hand back rows somebody took down on purpose.

Step 6: the detail page

// apps/web/app/(marketing)/products/[slug]/page.tsx
import { notFound } from "next/navigation";
import { formatMoney } from "@repo/shared/types";
import { getPublicProduct } from "@/lib/products-public";
type Props = { params: Promise<{ slug: string }> };
// The same fetch the page uses. getPublicProduct is wrapped in cache(), so
// this is free rather than a second request to the API.
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const product = await getPublicProduct((await params).slug);
if (!product) return { title: "Not found" };
return {
title: product.name,
description: product.description.slice(0, 160),
openGraph: product.image ? { images: [product.image.url] } : undefined,
};
}
export default async function ProductPage({ params }: Props) {
const product = await getPublicProduct((await params).slug);
// null is "no such product", which includes one that is archived. Anything
// else throws and the nearest error.tsx handles it: a broken API is not a
// missing product and must not render as one.
if (!product) notFound();
return (
<div className="mx-auto max-w-4xl px-6 py-16">
<h1 className="text-3xl font-bold tracking-tight">{product.name}</h1>
<p className="mt-4 font-mono text-xl text-accent">{formatMoney(product.price)}</p>
<p className="mt-6 leading-relaxed text-text-secondary">{product.description}</p>
</div>
);
}

The detail endpoint takes an id or a slug, which is why slug:slug[name] was in the generate command. A slug column is unique by construction, so /products/terrific-skateboard is a stable, readable, shareable address and /products/01a1c553-a490-7a63-9aea-9d57412aa567 is not.

Money is an integer, and a currency

The price field came back as {"amount":3674,"currency":"USD"}, and 3674 is not 36.74. amount is a count of the currency's smallest unit, which is a hundredth of a dollar, and is not a hundredth of everything: the Ugandan shilling has no minor unit at all, so 50000 UGX is USh 50,000 and not 500.

So never divide by 100 in a component. formatMoney from the shared package knows the exponent table and asks Intl.NumberFormat for the symbol, its position and the grouping separator, none of which are worth reimplementing:

import { formatMoney } from "@repo/shared/types";
formatMoney({ amount: 3674, currency: "USD" }) // "$36.74"
formatMoney({ amount: 50000, currency: "UGX" }) // "USh 50,000"

Publishing a field that was held back

stock was held back. To publish it, two edits, in two files, and both are yours to keep:

// apps/api/internal/handlers/product_public.go
type publicProduct struct {
// ...
Featured bool `json:"featured"`
Stock uint `json:"stock"` // added
}
func toPublicProduct(m models.Product) publicProduct {
return publicProduct{
// ...
Stock: m.Stock, // added
}
}
// apps/web/lib/products-public.ts
export interface PublicProduct {
// ...
featured: boolean;
stock: number; // added
}

Two files that have to agree is a shape worth being suspicious of, and the reason it is not generated from one is the reason the handler is never overwritten. If you would rather the type came from the Go side automatically, grit sync does that for the model types; the public view is deliberately outside it.

When to use the hooks instead

Everything above is for pages a stranger can open. For a page behind a sign-in, an order history, a saved basket, a profile, use the generated hooks in apps/web/hooks/use-products.ts. They call the authenticated routes with the session cookie, they cache and invalidate through React Query, and they can write. See Auth and protected pages.

One hard rule in the other direction. Nothing that writes goes on the public group. It is read-only on purpose and it is URL-cached, so a write mounted there would be served from a cache and would accept anything holding a publishable key, which is to say anything that has read your JavaScript. A public endpoint that writes, a contact form, a review, a waiting list, is hand-written and goes on its own route with its own rate limit and its own validation.

The whole path, in five commands. grit new shop --triple --next, grit generate resource Product --fields "..." --public --faker, grit migrate, grit seed, pnpm dev. Everything else on this page is the two React components you write.