Public catalog cheatsheet — Category + Product

Move catalog reads out of the protected group; cheatsheet for list, detail, by-category, related products.

13 minmedium

Most customer-facing apps ship with the same catalog endpoints: list categories, list products, view one product, filter products by category, show related products on a detail page. The previous lesson covered why some endpoints have to leave the protected route group; this one is the cheatsheet for what to wire up, using Category + Product as the worked example.

For each endpoint below you get the same four pieces:

  • What the generator already gave you -- handler method, service method, route entry. Often the answer is “already there, just move the route.”
  • What to add by hand -- the service + handler code when the endpoint is new, or the query-param plumbing when the existing handler needs to learn a new trick.
  • Route registration -- which group the route belongs in.
  • React Query hook -- the web-side hook customer pages call. The generator wrote a starter hook file; you extend it.

The starting point

Generate the two resources with the relationship:

grit generate resource Category --fields=name:string!,image:file
grit generate resource Product --fields=name:string!,price:int,thumbnail:file,description:text,images:files,category:belongs_to:Category
grit migrate

After the two generates, the routes file has every CRUD operation behind protected (the auth-required group). Open apps/api/internal/routes/routes.go and find the eight routes the generator created for you:

apps/api/internal/routes/routes.go (generated)
protected.GET("/categories", categoryHandler.List)
protected.GET("/categories/:id", categoryHandler.GetByID)
protected.POST("/categories", categoryHandler.Create)
protected.PUT("/categories/:id", categoryHandler.Update)
protected.PATCH("/categories/:id", categoryHandler.Patch)
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)

All ten lines need to move. The reads belong in a new public group; the writes belong in the admin group (which already exists in the scaffold for any resource the operator marked admin-only). Five reads -- four endpoints you'll expose publicly + four endpoints you'll add -- are the cheatsheet below.

Every “already there” section in this lesson refers to code the generator emitted on your first grit generate resource. If you regenerated with different fields the line numbers shift but the structure stays.

1. List categories

Public. Anonymous visitors browse the category navigation, so this is the most-hit endpoint on a typical catalog.

What you already have

categoryHandler.List is generated and works as is -- pagination, search, sort, filter, all wired through the shared paginate.List helper.

apps/api/internal/handlers/category.go (already generated)
func (h *CategoryHandler) List(c *gin.Context) {
query := h.DB.Model(&models.Category{})
res, err := paginate.List[models.Category](
query,
paginate.Bind(c),
paginate.Config{
Searchable: []string{"name", "slug"},
Sortable: map[string]bool{"id": true, "created_at": true, "name": true},
},
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{"code": "INTERNAL_ERROR", "message": "Failed to fetch categories"},
})
return
}
c.JSON(http.StatusOK, res)
}

What to change

Move the route out of protected into a new public group:

apps/api/internal/routes/routes.go
// PUBLIC: catalog. Anyone can browse.
public := r.Group("/api")
{
public.GET("/categories", categoryHandler.List)
public.GET("/categories/:id", categoryHandler.GetByID)
public.GET("/products", productHandler.List)
public.GET("/products/:id", productHandler.GetByID)
// ... add the four new endpoints below as you build them
}

React Query hook

The generator wrote apps/web/hooks/use-categories.ts with useCategories(...) already in it. No edit needed -- it just starts working once the route is public:

apps/web/app/(shop)/page.tsx
"use client";
import { useCategories } from "@/hooks/use-categories";
export default function HomePage() {
const { data, isLoading } = useCategories({ pageSize: 100 });
if (isLoading) return <p>Loading…</p>;
return (
<nav className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{data?.data.map((c) => (
<a key={c.id} href={"/category/" + c.slug} className="rounded-lg border p-4">
{c.name}
</a>
))}
</nav>
);
}

2. List products

Public. The product grid + the search bar. Both consumers (anonymous list page + admin) call this; the admin's axios just adds the auth cookie that the public version doesn't need.

What you already have

productHandler.List -- generated. Preloads the Category relation so the response includes nested category data; supports search, sort, pagination via the same paginate.List helper.

apps/api/internal/handlers/product.go (already generated)
func (h *ProductHandler) List(c *gin.Context) {
query := h.DB.Model(&models.Product{}).Preload("Category")
res, err := paginate.List[models.Product](
query,
paginate.Bind(c),
paginate.Config{
Searchable: []string{"name", "slug", "description"},
Sortable: map[string]bool{"id": true, "created_at": true, "name": true, "price": true},
},
)
if err != nil { /* 500 */ return }
c.JSON(http.StatusOK, res)
}

What to change

Just move the route. The handler already does everything you need.

React Query hook

useProducts(...) in apps/web/hooks/use-products.ts works as is. Same shape as useCategories; the response data.data[i].category is the nested object thanks to the Preload above.

3. Get single product

Public. The product detail page.

What you already have

apps/api/internal/handlers/product.go (already generated)
func (h *ProductHandler) GetByID(c *gin.Context) {
id := c.Param("id")
var item models.Product
if err := h.DB.Preload("Category").First(&item, "id = ?", id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Product not found"},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": item})
}

Bonus: lookup by slug too

Customer URLs read better as /p/red-running-shoes than /p/01HXP.... Add a second handler method that accepts either:

apps/api/internal/handlers/product.go (add)
// GetByIDOrSlug -- v3.31.49 cheatsheet. Tries ID first (UUIDs are
// 36 chars; slugs aren't), then slug. Lets customer URLs use the
// slug form without a second endpoint.
func (h *ProductHandler) GetByIDOrSlug(c *gin.Context) {
key := c.Param("id")
var item models.Product
q := h.DB.Preload("Category")
if len(key) == 36 {
q = q.Where("id = ?", key)
} else {
q = q.Where("slug = ?", key)
}
if err := q.First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Product not found"},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": item})
}

Then route it:

public.GET("/products/:id", productHandler.GetByIDOrSlug)

React Query hook

useGetProduct(id) already exists. It treats the param as a generic identifier, so passing either an ID or a slug just works once the handler is the slug-aware one:

apps/web/app/p/[slug]/page.tsx
"use client";
import { use } from "react";
import { useGetProduct } from "@/hooks/use-products";
export default function ProductDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const { data, isLoading, error } = useGetProduct(slug);
if (isLoading) return <p>Loading…</p>;
if (error || !data) return <p>Product not found.</p>;
return (
<article>
<h1>{data.name}</h1>
<p className="text-lg">UGX {data.price.toLocaleString()}</p>
<p className="text-text-muted">{data.category?.name}</p>
<p>{data.description}</p>
</article>
);
}

4. Get single category

Public. The category landing page header (“Phones & Tablets” with a hero image).

What you already have

categoryHandler.GetByID works as is. Add the same slug fallback you did for products if you want readable category URLs (/category/phones vs /category/01HXP...) -- pattern is identical.

5. List products by category

Public. The category landing page's product grid. Two ways to ship this -- pick one.

Option A: query param on the existing list endpoint (recommended)

Don't add a new endpoint. Teach productHandler.List a new filter:

apps/api/internal/handlers/product.go (extend List)
func (h *ProductHandler) List(c *gin.Context) {
query := h.DB.Model(&models.Product{}).Preload("Category")
// v3.31.49 cheatsheet -- filter by category. Accepts both
// ?category_id=<uuid> and ?category=<slug> so the customer
// app can pass whichever it has.
if catID := c.Query("category_id"); catID != "" {
query = query.Where("category_id = ?", catID)
} else if slug := c.Query("category"); slug != "" {
query = query.Joins("JOIN categories ON categories.id = products.category_id").
Where("categories.slug = ?", slug)
}
res, err := paginate.List[models.Product](
query,
paginate.Bind(c),
paginate.Config{
Searchable: []string{"name", "slug", "description"},
Sortable: map[string]bool{"id": true, "created_at": true, "name": true, "price": true},
},
)
if err != nil { /* 500 */ return }
c.JSON(http.StatusOK, res)
}

React Query hook -- extend useProducts

apps/web/hooks/use-products.ts (extend)
interface UseProductsParams {
page?: number;
pageSize?: number;
search?: string;
sortBy?: string;
sortOrder?: string;
// v3.31.49 cheatsheet -- either filter works; the API picks
// whichever it receives. categoryId is faster (indexed PK);
// categorySlug is friendlier in URLs.
categoryId?: string;
categorySlug?: string;
}
export function useProducts({
page = 1, pageSize = 20, search = "",
sortBy = "created_at", sortOrder = "desc",
categoryId, categorySlug,
}: UseProductsParams = {}) {
return useQuery<ProductsResponse>({
queryKey: ["products", { page, pageSize, search, sortBy, sortOrder, categoryId, categorySlug }],
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);
if (categoryId) params.set("category_id", categoryId);
if (categorySlug) params.set("category", categorySlug);
const { data } = await apiClient.get(`/api/products?${params}`);
return data;
},
});
}

Customer page:

apps/web/app/category/[slug]/page.tsx
"use client";
import { use } from "react";
import { useProducts } from "@/hooks/use-products";
export default function CategoryPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params);
const { data, isLoading } = useProducts({ categorySlug: slug, pageSize: 24 });
if (isLoading) return <p>Loading…</p>;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{data?.data.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
}

Option B: nested route (alternative)

If you prefer GET /api/categories/:slug/products over a query param, add a new handler method:

apps/api/internal/handlers/category.go (add)
// Products returns the paginated product list for one category.
// Nested-route flavour of the same data the
// GET /products?category=<slug> form returns.
func (h *CategoryHandler) Products(c *gin.Context) {
slug := c.Param("slug")
var cat models.Category
if err := h.DB.Where("slug = ?", slug).First(&cat).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": "Category not found"},
})
return
}
query := h.DB.Model(&models.Product{}).
Where("category_id = ?", cat.ID).
Preload("Category")
res, err := paginate.List[models.Product](
query,
paginate.Bind(c),
paginate.Config{
Searchable: []string{"name", "slug", "description"},
Sortable: map[string]bool{"id": true, "created_at": true, "name": true, "price": true},
},
)
if err != nil { /* 500 */ return }
c.JSON(http.StatusOK, res)
}
public.GET("/categories/:slug/products", categoryHandler.Products)
Pick one or the other -- don't ship both. The two return identical JSON; running both just doubles the surface area you have to keep in sync. Option A wins for 99% of catalogs because it reuses every search / sort / pagination feature the existing List handler already has.

6. Related products on the detail page

Public. The “You might also like” carousel under a product detail page. The cheap good-enough heuristic: top-N products in the same category, excluding the one being viewed.

Add a service method

Business logic stays in the service so the handler can stay thin. The generator left an empty surface in services/product.go -- add this method below the existing ones:

apps/api/internal/services/product.go (add)
// Related returns up to N products in the same category as the
// passed product, ordered by created_at desc, excluding the
// product itself. The simplest heuristic that still feels
// relevant -- swap in a smarter scoring later (tags, popularity,
// purchased-together) without touching the handler.
func (s *ProductService) Related(productID string, limit int) ([]models.Product, error) {
if limit <= 0 || limit > 50 {
limit = 8
}
// Look up the source product's category. One query, no preload
// needed -- we only want the category_id column.
var source models.Product
if err := s.DB.Select("id", "category_id").First(&source, "id = ?", productID).Error; err != nil {
return nil, fmt.Errorf("source product not found: %w", err)
}
var items []models.Product
err := s.DB.
Where("category_id = ? AND id <> ?", source.CategoryID, source.ID).
Order("created_at DESC").
Limit(limit).
Preload("Category").
Find(&items).Error
if err != nil {
return nil, fmt.Errorf("fetching related products: %w", err)
}
return items, nil
}

Add a handler method

apps/api/internal/handlers/product.go (add)
// Related is the public &ldquo;you might also like&rdquo; carousel.
// Reads a ?limit= query param (default 8, capped at 50 in the service).
// Returns a flat array under data{} for symmetry with the rest of the
// catalog endpoints.
func (h *ProductHandler) Related(c *gin.Context) {
id := c.Param("id")
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "8"))
svc := &services.ProductService{DB: h.DB}
items, err := svc.Related(id, limit)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{"code": "NOT_FOUND", "message": err.Error()},
})
return
}
c.JSON(http.StatusOK, gin.H{"data": items})
}

Don't forget the new import in the handler file -- strconv and "<your-module>/internal/services" (the latter is already imported by every generated handler, so nothing to add there).

Route it

public.GET("/products/:id/related", productHandler.Related)

React Query hook

Add a sibling hook in apps/web/hooks/use-products.ts:

apps/web/hooks/use-products.ts (add)
export function useRelatedProducts(id: string, limit = 8) {
return useQuery<{ data: Product[] }>({
queryKey: ["products", id, "related", limit],
queryFn: async () => {
const { data } = await apiClient.get(
`/api/products/${id}/related?limit=${limit}`,
);
return data;
},
enabled: !!id,
});
}

Use it on the product detail page

apps/web/app/p/[slug]/page.tsx (extend)
const { data: product } = useGetProduct(slug);
const { data: related } = useRelatedProducts(product?.id ?? "");
// ... below the product detail ...
{related?.data && related.data.length > 0 && (
<section className="mt-12">
<h2 className="mb-4 text-xl font-semibold">You might also like</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{related.data.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
</section>
)}

The full public route table

After all the moves + the two new endpoints, your routes.go public block should look like this:

apps/api/internal/routes/routes.go (final)
// PUBLIC: catalog. Anyone can browse.
public := r.Group("/api")
{
public.GET("/categories", categoryHandler.List)
public.GET("/categories/:id", categoryHandler.GetByID)
public.GET("/products", productHandler.List)
public.GET("/products/:id", productHandler.GetByIDOrSlug)
public.GET("/products/:id/related", productHandler.Related)
}
protected := r.Group("/api")
protected.Use(middleware.Auth(db, authService))
{
// Customer-account routes go here later (orders, profile, ...).
// grit:routes:protected
}
admin := r.Group("/api")
admin.Use(middleware.Auth(db, authService))
admin.Use(middleware.RequireRole("ADMIN"))
{
// Writes for the catalog -- staff only.
admin.POST("/categories", categoryHandler.Create)
admin.PUT("/categories/:id", categoryHandler.Update)
admin.PATCH("/categories/:id", categoryHandler.Patch)
admin.DELETE("/categories/:id", categoryHandler.Delete)
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
}
Make sure each route is registered in exactly one group. If you forget to delete the old protected.GET line after copying it to public.GET, Gin panics at startup with handlers are already registered for path.

Quick check

You moved `GET /products` from `protected` to `public` and the customer page loads. But hitting `POST /products` from the customer browser console still creates a product. What did you forget?

Try it

Ship the full catalog in your jumia / ecom project:

  1. Move the four GET /categories, GET /categories/:id, GET /products, GET /products/:id routes from protected to a new public group.
  2. Move all POST/PUT/PATCH/DELETE for both resources from protected to admin.
  3. Extend productHandler.List to honour ?category_id= and ?category= filters.
  4. Add GetByIDOrSlug on productHandler and route it.
  5. Add productService.Related + productHandler.Related, route at GET /products/:id/related.
  6. Build customer pages: / (categories), /category/[slug] (products in category), /p/[slug] (product detail + related).

Test as an anonymous visitor (incognito) -- you should see every page. Then try POST /api/products from the same incognito session -- it should return 401.

What's next

Catalog reads are now public, writes stay locked behind RequireRole("ADMIN"), and the customer pages call typed React Query hooks. Next chapter switches to going public in the other direction -- letting an anonymous visitor submit a public form for any resource via a token-gated link, no auth required. See concepts/generators/public-form-sharing.

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