Public catalog cheatsheet — Category + Product
Move catalog reads out of the protected group; cheatsheet for list, detail, by-category, related products.
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:filegrit generate resource Product --fields=name:string!,price:int,thumbnail:file,description:text,images:files,category:belongs_to:Categorygrit 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:
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.
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.
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:
// 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:
"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.
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
func (h *ProductHandler) GetByID(c *gin.Context) {id := c.Param("id")var item models.Productif 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:
// 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.Productq := 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:
"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:
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
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:
"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:
// 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.Categoryif 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)
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:
// 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.Productif 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.Producterr := s.DB.Where("category_id = ? AND id <> ?", source.CategoryID, source.ID).Order("created_at DESC").Limit(limit).Preload("Category").Find(&items).Errorif err != nil {return nil, fmt.Errorf("fetching related products: %w", err)}return items, nil}
Add a handler method
// Related is the public “you might also like” 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:
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
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:
// 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}
protected.GET line after copying it to public.GET, Gin panics at startup with handlers are already registered for path.Quick check
Try it
Ship the full catalog in your jumia / ecom project:
- Move the four
GET /categories,GET /categories/:id,GET /products,GET /products/:idroutes fromprotectedto a newpublicgroup. - Move all
POST/PUT/PATCH/DELETEfor both resources fromprotectedtoadmin. - Extend
productHandler.Listto honour?category_id=and?category=filters. - Add
GetByIDOrSlugonproductHandlerand route it. - Add
productService.Related+productHandler.Related, route atGET /products/:id/related. - 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