Custom API endpoints, end to end
Build a small shop, see exactly which endpoints Grit gives you for nothing, then write four of your own: three public, one that only signed-in users can call. Then call all four from Next.js and from TanStack Start.
Written for someone who has never touched a handler or a service. Every command and every response on this page was run against a real project.
What we are building
A shop with three resources. They are chosen so that each one teaches something the last one did not.
- CategoryA name, a slug, an image. Hierarchical, so Laptops can sit under Computers, and public, so the storefront can read it without a login.
- ProductBelongs to one category. This is the simplest relationship, and the one that changes the least.
- CampaignHas many products, and a product can be in many campaigns. This is the relationship that changes the most.
Along the way: seeding four categories from a file you control, adding a column to a resource that already exists, and then the part this page is really about, which is writing endpoints Grit did not generate.
Step 1: the project
$grit new shop --triple --next$cd shop
--triple is three apps in one repository: the Go API, a public web app, and an admin panel. --next makes both frontends Next.js. They share one packages/shared, so a type generated from a Go struct is the same type in all three.
Open .env and point it at a database. SQLite needs nothing installed and is fine for this whole tutorial:
DATABASE_URL=sqlite:./app.db
Step 2: the Category resource
$grit generate resource Category \$ --fields "name:string,slug:slug:name,image:file:image" \$ --tree --public
Three things there are worth slowing down on.
- slug:slug:nameA URL-safe version of another field. The third segment names the source, so this slugifies name. Filled on save, unique, and never shown in the form: it is not something a person types.
- --treeMakes the resource hierarchical. Adds parent_id, plus path, depth and position, and five endpoints for moving rows around. A category can now sit under another category.
- --publicAdds a second, read-only copy of the list and detail endpoints under /api/v1/public/, outside the login. This is what a storefront reads.
The command prints what it wrote. The files that matter for this page:
apps/api/internal/models/category.go the struct, and the database tableapps/api/internal/services/category.go business logic (yours to grow)apps/api/internal/handlers/category.go HTTP in, JSON outapps/api/internal/routes/category_routes.go every URL this resource answerspackages/shared/types/category.ts the TypeScript typeapps/web/hooks/use-categories.ts React Query hooksapps/admin/resources/categories/ the admin screen
internal/routes/<resource>_routes.go. Creating that file mounts the resource and deleting it unmounts it, so routes.go does not grow as you add resources. You will add your own routes file later, and it works exactly the same way.Step 3: seeding four categories
An empty database makes everything harder to check. Grit can fill one with gofakeit via --faker, but random words are no good here: we want four categories we can name in a test. So generate the seeder on its own and edit it.
$grit generate seeder Category
That writes apps/api/internal/database/categories_seeder.go with one example row, and registers it in seed.go. Replace the row with four:
func SeedCategories(db *gorm.DB) error {// Idempotent: running it twice does not double the rows. Every generated// seeder starts with this, and yours should keep it.var count int64db.Model(&models.Category{}).Count(&count)if count > 0 {log.Println("Categories already seeded, skipping...")return nil}records := []models.Category{{Name: "Laptops", IsFeatured: true,Image: &files.FileRef{URL: "https://picsum.photos/seed/laptops/600/400", Name: "laptops.jpg", MIME: "image/jpeg"}},{Name: "Phones", IsFeatured: true,Image: &files.FileRef{URL: "https://picsum.photos/seed/phones/600/400", Name: "phones.jpg", MIME: "image/jpeg"}},{Name: "Audio", IsFeatured: false,Image: &files.FileRef{URL: "https://picsum.photos/seed/audio/600/400", Name: "audio.jpg", MIME: "image/jpeg"}},{Name: "Accessories", IsFeatured: false,Image: &files.FileRef{URL: "https://picsum.photos/seed/accessories/600/400", Name: "accessories.jpg", MIME: "image/jpeg"}},}for _, r := range records {if err := db.Create(&r).Error; err != nil {log.Printf("Warning: failed to seed category: %v", err)}}log.Printf("Seeded %d category(s)", len(records))return nil}
Notice what is not in that list: no ID, no Slug, no Path or Depth. The id is generated on save, the slug is derived from the name, and the tree columns are computed from the parent. Setting them by hand would fight the model.
$grit migrate # create the tables$grit seed # run every seeder# Seeded 4 category(s)
IsFeatured in that seeder does not exist yet. Add it next, then come back and run grit seed.
Step 4: adding a field to a resource that already exists
You do not regenerate the resource. Regenerating rewrites the model, the handler and the admin screen, and takes any edit you made with it.
$grit generate field Category is_featured:toggle
One command, five files:
- The Go modelIsFeatured bool, added after your other fields.
- The Zod schemasCreate and update both accept it.
- The TypeScript typeis_featured: boolean.
- The admin formA switch.
- The admin tableA yes/no column.
grit migrate reads the struct and adds the column. There is nothing to write and nothing to check in.$grit migrate$grit seed
toggle is a bool that renders as a switch. For anything richer, the same command takes status:select:draft=Draft|live=Live, notes:text, and the other scalar types. Relationships, files and slugs are the exception: those change too much, so regenerate the resource instead.
Step 5: Product, and what a relationship changes
$grit generate resource Product \$ --fields "name:string,slug:slug:name,description:text,price:money,stock:int,image:file:image,category:belongs_to:Category" \$ --public
category:belongs_to:Category is the whole relationship. Everything else follows from it.
What belongs_to actually does
In the model, one field becomes two:
// The column. A UUID string, indexed, and this is what the database stores.CategoryID string `gorm:"size:36;index" json:"category_id"`// The relation. NOT a column. GORM fills this when you ask it to, and// leaves it nil when you do not.Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
That distinction is the single most useful thing to understand about relationships in Grit. CategoryID is data. Category is a convenience that costs a query. If you list a thousand products and never Preload, you get a thousand category_id strings and a thousand null categories, and the page is fast. If you do preload, you get the names, and it costs one extra query for the whole page.
// No preload: category is null in the JSON.db.Find(&products)// One extra query for the whole page, not one per row.db.Preload("Category").Find(&products)
Elsewhere, the same field shows up as:
- In the admin formA searchable dropdown that loads categories from the API, rather than a text box you type a UUID into.
- In the list endpoint?category_id=<id> filters by it, because the generated handler whitelists the foreign key column.
- In TypeScriptcategory_id: string, and category?: Category.
- In the seederGrit looks up real category ids and picks one, rather than inventing a UUID that points at nothing.
Step 6: Campaign, and many-to-many
$grit generate resource Campaign \$ --fields "title:string,subtitle:string,slug:slug:title,description:text,image:file:image,products:many_to_many:Product" \$ --public
A product can be in several campaigns and a campaign holds several products, so neither table can hold the other's id. GORM creates a third table with nothing in it but the two keys:
campaign_productscampaign_id varchar(36)product_id varchar(36)
// No CampaignID on Product, and no ProductID on Campaign. The join table// is the relationship, and many2many names it.Products []Product `gorm:"many2many:campaign_products;" json:"products,omitempty"`
How it differs from belongs_to, in practice
| belongs_to | many_to_many | |
|---|---|---|
| Stored as | A column on this table | A separate join table |
| Set on create | category_id: "..." | product_ids: ["id", "id"] |
| Reading it | Preload("Category") | Preload("Products") |
| Changing it | Update the column | Replace the whole set |
| Admin control | Searchable dropdown | Multi-select picker |
| Can be empty | Yes, the column is nullable | Yes, no join rows |
"Replace the whole set" is the one that surprises people. Sending product_ids: ["a", "b"] does not add two products, it makes the campaign hold exactly those two. Sending product_ids: [] empties it. That is what a multi-select does, and it is what the admin picker sends.
A belongs_to is the opposite by default: category_id is generated with binding:"required", so a product without one is a 422 rather than a row with a dangling reference. Drop the binding tag in the request struct if you want products that are not filed anywhere yet.
What you already have, without writing an endpoint
This is the part worth knowing before you write anything. Each of the three resources above already answers every URL below. Nothing was hand-written, and the list is read straight out of internal/routes/<resource>_routes.go, which you can open.
Public: no login, API key only
Only exists because the resource was generated with --public. Read only: there is no POST, PUT or DELETE here, by design. A storefront reads; it does not write.
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/public/categories | Paginated list. Read-only. |
| GET | /api/v1/public/categories/:key | One row, by id or slug. |
| GET | /api/v1/public/categories/:key/related | Rows that point at this one. |
| GET | /api/v1/public/categories/tree | The whole hierarchy, one query. Only with --tree. |
:key accepts an id or a slug, so /public/categories/laptops works.
Protected: a signed-in user, or an API key
The everyday CRUD surface. The middleware on this group rejects anonymous requests before your code runs, so a handler here never has to check.
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/categories | List, with search, filters, sort and pagination. |
| GET | /api/v1/categories/:id | One row. |
| POST | /api/v1/categories | Create. |
| PUT | /api/v1/categories/:id | Replace. |
| PATCH | /api/v1/categories/:id | Update the fields you send. |
| GET | /api/v1/categories/export | CSV or XLSX of the current filter. |
| POST | /api/v1/categories/import | Bulk import from a spreadsheet. |
| GET | /api/v1/categories/import/template | A blank import file with the right headers. |
| GET | /api/v1/categories/:id/pdf | The record as a PDF. |
Admin: the ADMIN role as well
Destructive operations sit behind a second check. Bulk is here rather than with PATCH because bulk can delete, and a route is only as protected as its most destructive branch.
| Method | Path | What it does |
|---|---|---|
| DELETE | /api/v1/categories/:id | Soft delete. ADMIN role required. |
| POST | /api/v1/categories/bulk | Act on many rows at once, delete included. |
Tree: only because Category used --tree
| Method | Path | What it does |
|---|---|---|
| GET | /api/v1/categories/tree | Nested rows. |
| GET | /api/v1/categories/:id/breadcrumbs | The path from the root to this row. |
| PATCH | /api/v1/categories/:id/move | Reparent, refusing to make a row its own ancestor. |
| POST | /api/v1/categories/reorder | Reorder siblings. |
| POST | /api/v1/categories/rebuild-tree | Recompute every path and depth. |
Thirteen protected endpoints, four public ones and five tree ones for Category. Product and Campaign get the same, minus the tree. Swap categories for products or campaigns in any path above and it exists.
grit start and open http://localhost:8080/docs. Every endpoint, with request and response shapes, generated from the router itself, so it cannot drift from what the server actually serves.Handlers and services, from scratch
If you have never written either, this is the section to read twice. Everything after it is an application of these four paragraphs.
The journey of one request
Browser│ GET /api/v1/public/categories/featured?limit=8▼Router routes/catalog_routes.go│ matches the URL, picks the handler▼Middleware checks the API key, the rate limit, CORS│ rejects here, before your code runs▼Handler handlers/catalog.go│ reads ?limit, calls one service method,│ turns the answer into a status code▼Service services/catalog.go│ the actual question: which categories are featured▼GORM builds SQL, binds parameters, scans rows into structs▼Database
The handler: everything about HTTP, nothing about the business
A handler is a function that takes a *gin.Context and returns nothing. The context is the request and the response together: you read the URL, the query string, the body and the signed-in user out of it, and you write the status and the JSON back into it.
A good handler does exactly four things, in order:
- Reads what the request is asking for.
- Calls one service method.
- Turns an error into a status code.
- Writes JSON.
If a handler is doing anything else, particularly if it contains the word Where, that logic has nowhere to be reused from. A background job cannot call a handler. Neither can a CLI command, nor another handler, nor a test, without constructing a fake HTTP request first.
The service: everything about the business, nothing about HTTP
A service is a plain struct holding a *gorm.DB, with methods on it. It does not import gin. It does not know what a status code is. It takes arguments and returns values and errors, which means anything can call it.
// The service says what happened.func (s *CatalogService) CampaignBySlug(slug string) (*CampaignDetail, error) {...if errors.Is(err, gorm.ErrRecordNotFound) {return nil, ErrNotFound}}// The handler decides what that means over HTTP.if errors.Is(err, services.ErrNotFound) {fail(c, http.StatusNotFound, "NOT_FOUND", "Campaign not found")return}
That split is the whole idea. "Not found" is a fact about the data. "404" is a fact about HTTP. Returning a 404 from the service would make the same method useless to a scheduled job, which has no response to write to.
paginate helper, and the generated services/<resource>.go is a starting point nothing calls yet. Plain CRUD has no business logic to put in a service, and a method that only forwards a call is a layer that costs a file and pays nothing. The moment you have a real rule, that file is where it goes, and everything below shows how.Where the database connection comes from
You never open a connection. cmd/server/main.go opens one pool at boot, and every request borrows a connection from it and gives it back. Opening one per request would exhaust the database in minutes.
// cmd/server/main.go, at boot, once.db, err := database.Connect(cfg)// routes/routes.go hands it to every resource's routes file.mountResources(&Mount{ DB: db, ... })// your routes file puts it in the handler.h := handlers.NewCatalogHandler(m.DB)// the handler passes it to the service.&services.CatalogService{DB: db}
One *gorm.DB, passed down. That is the whole dependency chain, and it is why a handler can be constructed in a test with an in-memory SQLite database and no server at all.
GORM cheat sheet
GORM turns Go structs into SQL. Everything below is chainable, and nothing executes until a finisher: Find, First, Count, Create, Updates, Delete.
| Go | SQL | Worth knowing |
|---|---|---|
| db.Find(&items) | SELECT * FROM products | Many rows. No error when nothing matches: an empty slice is an answer. |
| db.First(&item, "id = ?", id) | SELECT * FROM products WHERE id = ? LIMIT 1 | One row. Returns gorm.ErrRecordNotFound when there is none, which is the 404. |
| db.Where("stock > ?", 0).Find(&items) | SELECT * FROM products WHERE stock > 0 | Always ? placeholders. String concatenation here is SQL injection. |
| db.Order("created_at DESC").Limit(10).Find(&items) | ORDER BY created_at DESC LIMIT 10 | Chainable. Nothing runs until Find, First or Count. |
| db.Create(&item) | INSERT INTO products (...) VALUES (...) | Fills item.ID and the timestamps in place. Pass a pointer or it cannot. |
| db.Model(&item).Updates(map[string]any{...}) | UPDATE products SET ... WHERE id = ? | A map updates zero and false; a struct skips them, because it cannot tell zero from unset. |
| db.Delete(&models.Product{}, "id = ?", id) | UPDATE products SET deleted_at = now() WHERE id = ? | A soft delete, because the model has gorm.DeletedAt. The row stays and stops being found. |
| db.Model(&models.Product{}).Count(&n) | SELECT count(*) FROM products | Model, not Find. Counting into a slice loads every row to throw it away. |
| db.Preload("Category").Find(&items) | two queries: products, then categories WHERE id IN (...) | Fills item.Category. Without it that field is nil and the JSON shows null. |
| db.Transaction(func(tx *gorm.DB) error { ... }) | BEGIN ... COMMIT / ROLLBACK | Return an error and everything rolls back. Use tx inside, never db. |
db.Create(item) cannot write the generated id back; db.Create(&item) can. Updates with a struct skips zero values, so setting a bool to false or a count to 0 silently does nothing: use a map. Find never errors on an empty result, so checking err != nil after it will not tell you the list was empty; check the length.N+1, the one that ruins a page
// 1 query, then 1 more per product. 200 products is 201 queries.db.Find(&products)for i := range products {db.First(&products[i].Category, "id = ?", products[i].CategoryID)}// 2 queries, whatever the number of products.db.Preload("Category").Find(&products)
It is invisible with ten rows in development and obvious with ten thousand in production. If a loop contains a query, that is the shape.
Writing your own endpoints
Four of them: three public, one that requires a login. Same pattern each time, three files.
services/catalog.go: the question and its answer.handlers/catalog.go: request in, status and JSON out.routes/catalog_routes.go: the URLs.
Name them after what they do, not after a model. These queries all answer "what does the shop front page need", so one catalog triple holds all four rather than scattering one method into each of three files.
1. Featured categories, newest first
package servicesimport ("errors""fmt""gorm.io/gorm""shop/apps/api/internal/models")// CatalogService is the business logic behind the storefront's own endpoints.type CatalogService struct {DB *gorm.DB}// FeaturedCategories returns the categories marked featured, newest first.//// limit is capped rather than trusted. It arrives from a query string, and// ?limit=100000 on a public endpoint is a cheap way to make the database do// expensive work.func (s *CatalogService) FeaturedCategories(limit int) ([]models.Category, error) {if limit < 1 || limit > 50 {limit = 12}var items []models.Categoryerr := s.DB.Where("is_featured = ?", true).Where("archived_at IS NULL").Order("created_at DESC").Limit(limit).Find(&items).Errorif err != nil {return nil, fmt.Errorf("fetching featured categories: %w", err)}return items, nil}
archived_at IS NULL is not optional. Grit gives every resource an archive that hides rows from the admin's default list, and a custom query that forgets it shows the storefront things somebody deliberately put away.
2. Categories with a product count
// CategoryWithCount is a category plus how many products point at it.//// Its own type rather than models.Category, because product_count is not a// column. Adding a non-column field to the model would make it appear in the// admin table, the generated TypeScript type and the API reference as though// it were stored.type CategoryWithCount struct {models.CategoryProductCount int64 `json:"product_count"`}// CategoriesWithProductCounts returns every live category and its product// count, in one query.//// The obvious version loads the categories and then counts products for each// one: one query plus one per row, the N+1 above. A LEFT JOIN with GROUP BY// asks the database the whole question once.//// LEFT, not INNER: an inner join drops categories with no products, and a// category with nothing in it is exactly the one an admin is looking for.func (s *CatalogService) CategoriesWithProductCounts() ([]CategoryWithCount, error) {var rows []CategoryWithCounterr := s.DB.Model(&models.Category{}).Select("categories.*, COUNT(products.id) AS product_count").Joins("LEFT JOIN products ON products.category_id = categories.id AND products.deleted_at IS NULL").Where("categories.archived_at IS NULL").Group("categories.id").Order("product_count DESC").Find(&rows).Errorif err != nil {return nil, fmt.Errorf("counting products per category: %w", err)}return rows, nil}
products.deleted_at IS NULL lives in the JOIN condition, not the WHERE. In the WHERE it would drop categories whose only products are deleted, turning the LEFT JOIN back into an INNER one.
3. A campaign detail page, by slug
// ErrNotFound is what a handler turns into a 404.//// The service says "not found"; the handler decides that means 404. Returning// a gin status from here would tie the business logic to HTTP, and the same// method is then unusable from a background job or a CLI command.var ErrNotFound = errors.New("not found")type CampaignDetail struct {Campaign models.Campaign `json:"campaign"`Products []models.Product `json:"products"`Likes int64 `json:"likes"`Liked bool `json:"liked"`}// CampaignBySlug loads a campaign's detail page.//// By slug, not id: /campaigns/summer-sale is the URL a person can read and a// search engine can index.//// viewerID is empty for a signed-out visitor, and Liked is then false without// a query. Asking "has nobody liked this" is a question with a known answer.func (s *CatalogService) CampaignBySlug(slug, viewerID string) (*CampaignDetail, error) {var campaign models.Campaign// Preload pulls the many-to-many in a second query keyed by the ids found// in the first, rather than a join that repeats every campaign column once// per product.err := s.DB.Preload("Products").Where("slug = ?", slug).Where("archived_at IS NULL").First(&campaign).Errorif errors.Is(err, gorm.ErrRecordNotFound) {return nil, ErrNotFound}if err != nil {return nil, fmt.Errorf("fetching campaign %q: %w", slug, err)}detail := CampaignDetail{Campaign: campaign, Products: campaign.Products}if err := s.DB.Model(&models.CampaignLike{}).Where("campaign_id = ?", campaign.ID).Count(&detail.Likes).Error; err != nil {return nil, fmt.Errorf("counting likes: %w", err)}if viewerID != "" {var mine int64if err := s.DB.Model(&models.CampaignLike{}).Where("campaign_id = ? AND user_id = ?", campaign.ID, viewerID).Count(&mine).Error; err != nil {return nil, fmt.Errorf("checking like: %w", err)}detail.Liked = mine > 0}return &detail, nil}
4. Liking a campaign, for signed-in users only
First a model, because a like has to be stored somewhere:
package modelsimport ("time""gorm.io/gorm""shop/apps/api/internal/ids")// CampaignLike is one user liking one campaign.//// A join row rather than a counter on Campaign. A counter cannot answer "has// this user already liked it", which is the first thing the button needs to// know, and two people liking at once would race on a read-modify-write.type CampaignLike struct {ID string `gorm:"primarykey;size:36" json:"id"`// The pair is unique, so liking twice is refused by the database rather// than by a check that two concurrent requests can both pass.UserID string `gorm:"size:36;not null;uniqueIndex:idx_campaign_like,priority:1" json:"user_id"`CampaignID string `gorm:"size:36;not null;uniqueIndex:idx_campaign_like,priority:2" json:"campaign_id"`CreatedAt time.Time `json:"created_at"`}func (m *CampaignLike) BeforeCreate(tx *gorm.DB) error {if m.ID == "" {m.ID = ids.New()}return nil}
Register it so grit migrate creates the table. Open internal/models/user.go and add one line above the marker:
func Models() []interface{} {return []interface{}{&User{},// ...&CampaignLike{},// grit:models}}
// grit:models is where grit generate resource inserts the next model. Delete it and future resources are generated but never migrated, and the failure is a missing table at runtime rather than an error at generation time.// LikeCampaign records that a user likes a campaign, and returns the new total.//// Idempotent: liking twice leaves one row and is not an error. The button can// be double-clicked and the request can be retried by a flaky network, and// neither should produce a second like or a red toast.//// FirstOrCreate rather than "check, then insert", because two requests can// both pass the check. The unique index is what actually enforces this;// FirstOrCreate just avoids hitting it in the common case.func (s *CatalogService) LikeCampaign(campaignID, userID string) (int64, error) {var campaign models.Campaignerr := s.DB.Select("id").Where("id = ?", campaignID).First(&campaign).Errorif errors.Is(err, gorm.ErrRecordNotFound) {return 0, ErrNotFound}if err != nil {return 0, fmt.Errorf("loading campaign: %w", err)}like := models.CampaignLike{CampaignID: campaignID, UserID: userID}if err := s.DB.Where("campaign_id = ? AND user_id = ?", campaignID, userID).FirstOrCreate(&like).Error; err != nil {return 0, fmt.Errorf("liking campaign: %w", err)}return s.campaignLikes(campaignID)}// UnlikeCampaign removes a like. Removing one that is not there is not an// error either, for the same reason.func (s *CatalogService) UnlikeCampaign(campaignID, userID string) (int64, error) {if err := s.DB.Where("campaign_id = ? AND user_id = ?", campaignID, userID).Delete(&models.CampaignLike{}).Error; err != nil {return 0, fmt.Errorf("unliking campaign: %w", err)}return s.campaignLikes(campaignID)}func (s *CatalogService) campaignLikes(campaignID string) (int64, error) {var n int64err := s.DB.Model(&models.CampaignLike{}).Where("campaign_id = ?", campaignID).Count(&n).Errorreturn n, err}
The handler
package handlersimport ("errors""net/http""strconv""github.com/gin-gonic/gin""gorm.io/gorm""shop/apps/api/internal/services")// CatalogHandler holds a service, not a *gorm.DB. Every method below reads the// request, calls one service method, and turns the result into a status code.type CatalogHandler struct {Catalog *services.CatalogService}func NewCatalogHandler(db *gorm.DB) *CatalogHandler {return &CatalogHandler{Catalog: &services.CatalogService{DB: db}}}// fail() is not defined here. It already exists in this package, in// recovery.go, and writes the { "error": { "code", "message" } } envelope every// Grit endpoint uses. Handlers share one package, so a second copy is a// compile error rather than a quiet inconsistency.// GET /api/v1/public/categories/featured?limit=8func (h *CatalogHandler) FeaturedCategories(c *gin.Context) {// Atoi returns 0 on anything unparseable, and the service treats 0 as// "use the default". A missing or nonsense limit is not worth a 400.limit, _ := strconv.Atoi(c.Query("limit"))items, err := h.Catalog.FeaturedCategories(limit)if err != nil {fail(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to fetch featured categories")return}c.JSON(http.StatusOK, gin.H{"data": items})}// GET /api/v1/public/categories/with-countsfunc (h *CatalogHandler) CategoriesWithCounts(c *gin.Context) {rows, err := h.Catalog.CategoriesWithProductCounts()if err != nil {fail(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to fetch categories")return}c.JSON(http.StatusOK, gin.H{"data": rows})}// GET /api/v1/public/campaigns/:key/detail//// The parameter is :key, not :slug, and that is not a preference. Gin builds// one routing tree, so two routes sharing a path position must name the// wildcard there identically. The generated route is /campaigns/:key, so this// one has to be :key too, and registering :slug panics the router at boot.func (h *CatalogHandler) CampaignDetail(c *gin.Context) {detail, err := h.Catalog.CampaignBySlug(c.Param("key"), c.GetString("user_id"))if errors.Is(err, services.ErrNotFound) {fail(c, http.StatusNotFound, "NOT_FOUND", "Campaign not found")return}if err != nil {fail(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to fetch campaign")return}c.JSON(http.StatusOK, gin.H{"data": detail})}// POST /api/v1/campaigns/:id/like//// The user id comes from the token, never from the body: a user_id in the// request would let anyone like on somebody else's behalf.func (h *CatalogHandler) LikeCampaign(c *gin.Context) {userID := c.GetString("user_id")if userID == "" {fail(c, http.StatusUnauthorized, "UNAUTHORIZED", "Sign in to like a campaign")return}likes, err := h.Catalog.LikeCampaign(c.Param("id"), userID)if errors.Is(err, services.ErrNotFound) {fail(c, http.StatusNotFound, "NOT_FOUND", "Campaign not found")return}if err != nil {fail(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to like campaign")return}c.JSON(http.StatusOK, gin.H{"data": gin.H{"likes": likes, "liked": true},"message": "Campaign liked",})}// DELETE /api/v1/campaigns/:id/like//// The mirror image, and worth having: a like button that cannot be undone is a// like button people stop pressing.func (h *CatalogHandler) UnlikeCampaign(c *gin.Context) {userID := c.GetString("user_id")if userID == "" {fail(c, http.StatusUnauthorized, "UNAUTHORIZED", "Sign in to like a campaign")return}likes, err := h.Catalog.UnlikeCampaign(c.Param("id"), userID)if err != nil {fail(c, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to unlike campaign")return}c.JSON(http.StatusOK, gin.H{"data": gin.H{"likes": likes, "liked": false},"message": "Campaign unliked",})}
The routes
package routesimport ("shop/apps/api/internal/handlers")// Storefront routes: the endpoints this project added by hand.//// Its own file, next to the generated <resource>_routes.go files, and it works// the same way: an init() that registers itself, so nothing else has to know// this file exists. "grit generate resource" will never touch it, and// "grit remove resource" will never delete it.func init() {RegisterRoutes(func(m *Mount) {h := handlers.NewCatalogHandler(m.DB)// Public: no login. The group already requires an API key.//// "/categories/featured" is registered alongside the generated// "/categories/:key". Gin routes a static segment ahead of a// parameter, so /categories/featured reaches this and// /categories/laptops reaches the generated handler.m.Public.GET("/categories/featured", h.FeaturedCategories)m.Public.GET("/categories/with-counts", h.CategoriesWithCounts)m.Public.GET("/campaigns/:key/detail", h.CampaignDetail)// Protected: a valid JWT or API key. The middleware on this group has// already rejected everyone else before the handler runs.m.Protected.POST("/campaigns/:id/like", h.LikeCampaign)m.Protected.DELETE("/campaigns/:id/like", h.UnlikeCampaign)})}
Which group you register on is the security decision. There is no check inside FeaturedCategories because m.Public already said what it is, and no check inside LikeCampaign beyond reading the user, because m.Protected already rejected anyone without a token.
| Group | Prefix | Who gets through |
|---|---|---|
| m.Public | /api/v1/public/ | Anyone with a valid API key. No user. |
| m.Protected | /api/v1/ | A valid JWT, or an API key. |
| m.Admin | /api/v1/ | The above, and the ADMIN role. |
| m.V1 | /api/v1/ | No middleware at all. You add your own. |
$grit migrate # creates campaign_likes$cd apps/api && go build ./...$grit start
Then check they answer:
# public: needs an API key, no login$curl -H "X-API-Key: $KEY" \$ http://localhost:8080/api/v1/public/categories/featured?limit=8# {"data":[{"name":"Phones",...},{"name":"Laptops",...}]}$curl -H "X-API-Key: $KEY" \$ http://localhost:8080/api/v1/public/categories/with-counts# {"data":[{"name":"Accessories","product_count":9}, ...]}# protected: needs a token$curl -X POST -H "Authorization: Bearer $TOKEN" \$ http://localhost:8080/api/v1/campaigns/$ID/like# {"data":{"liked":true,"likes":1},"message":"Campaign liked"}# without one$curl -X POST http://localhost:8080/api/v1/campaigns/$ID/like# 401 {"error":{"code":"UNAUTHORIZED","message":"Authentication required"}}
POST /api/v1/api-keys while signed in. Use a publishable key for a storefront: it is the one meant to be visible in a browser. The secret kind belongs on a server only.Calling them from Next.js
Two shapes, and picking the right one is most of the work. Public data is fetched on the server, where the API key never reaches the browser. Anything tied to the signed-in user is fetched in the browser, where the session lives.
Public data, in a server component
import type { Campaign, Category, Product } from "@repo/shared/types";// No NEXT_PUBLIC_ prefix. That prefix is what ships a variable to the browser,// and this key must not go there.const API = process.env.API_URL ?? "http://localhost:8080";const KEY = process.env.GRIT_API_KEY!;export type CategoryWithCount = Category & { product_count: number };// Mirrors the CampaignDetail struct the service returns. Category, Campaign// and Product all come from @repo/shared/types, generated from the Go models,// so those three cannot drift from the API.export type CampaignDetail = {campaign: Campaign;products: Product[];likes: number;liked: boolean;};async function publicGet<T>(path: string, revalidate = 60): Promise<T> {const res = await fetch(`${API}/api/v1/public${path}`, {headers: { "X-API-Key": KEY },// Cache the response for a minute. Without this, Next fetches on every// request and the API sees your traffic rather than your cache.next: { revalidate },});if (!res.ok) {throw new Error(`${path} failed: ${res.status}`);}const body = await res.json();return body.data as T;}export const getFeaturedCategories = (limit = 8) =>publicGet<Category[]>(`/categories/featured?limit=${limit}`);export const getCategoriesWithCounts = () =>publicGet<CategoryWithCount[]>("/categories/with-counts");export const getCampaign = (slug: string) =>publicGet<CampaignDetail>(`/campaigns/${slug}/detail`);
import Image from "next/image";import Link from "next/link";import { getFeaturedCategories } from "@/lib/catalog";// A server component. It runs on the server, so the API key stays there and// the browser is sent finished HTML.export default async function HomePage() {const categories = await getFeaturedCategories(8);return (<section className="grid grid-cols-2 md:grid-cols-4 gap-4">{categories.map((c) => (<Link key={c.id} href={`/categories/${c.slug}`} className="group">{c.image && (<Imagesrc={c.image.url}alt={c.name}width={600}height={400}className="rounded-lg object-cover aspect-[3/2]"/>)}<h3 className="mt-2 font-medium group-hover:underline">{c.name}</h3></Link>))}</section>);}
import { notFound } from "next/navigation";import { getCampaign } from "@/lib/catalog";import { LikeButton } from "./like-button";export default async function CampaignPage({params,}: {params: Promise<{ slug: string }>;}) {const { slug } = await params;let detail;try {detail = await getCampaign(slug);} catch {// The service returned ErrNotFound, the handler made it a 404, and the// fetch helper threw. This page turns that into Next's own 404.notFound();}return (<article><h1 className="text-3xl font-bold">{detail.campaign.title}</h1><p className="text-muted-foreground">{detail.campaign.subtitle}</p>{/* Server-rendered count, then a client component takes over. */}<LikeButtoncampaignId={detail.campaign.id}initialLikes={detail.likes}initialLiked={detail.liked}/><ul className="mt-8 grid grid-cols-3 gap-4">{detail.products.map((p) => (<li key={p.id}>{p.name}</li>))}</ul></article>);}
The protected call, in a client component
Liking needs the signed-in user, so it happens in the browser. The generated lib/api.ts already attaches the session cookie and the CSRF header, so there is no token to pass by hand.
"use client";import { useMutation, useQueryClient } from "@tanstack/react-query";import { useState } from "react";import { apiClient } from "@/lib/api";export function LikeButton({campaignId,initialLikes,initialLiked,}: {campaignId: string;initialLikes: number;initialLiked: boolean;}) {const [likes, setLikes] = useState(initialLikes);const [liked, setLiked] = useState(initialLiked);const qc = useQueryClient();const toggle = useMutation({mutationFn: async () => {// apiClient rewrites /api/... to /api/v1/... and sends the session// cookie, so this is the protected endpoint, not the public one.const { data } = liked? await apiClient.delete(`/api/campaigns/${campaignId}/like`): await apiClient.post(`/api/campaigns/${campaignId}/like`);return data.data as { likes: number; liked: boolean };},onSuccess: (d) => {setLikes(d.likes);setLiked(d.liked);qc.invalidateQueries({ queryKey: ["campaigns", campaignId] });},onError: (err: any) => {// 401 means signed out. Everything else is a real failure.if (err?.response?.status === 401) {window.location.href = "/login";}},});return (<buttononClick={() => toggle.mutate()}disabled={toggle.isPending}aria-pressed={liked}className="inline-flex items-center gap-2 rounded-lg border px-4 py-2 disabled:opacity-50"><span aria-hidden>{liked ? "♥" : "♡"}</span>{likes} {likes === 1 ? "like" : "likes"}</button>);}
Calling them from TanStack Start
Same two shapes. A server function replaces the server component, and the client half is identical because both frontends use React Query.
import { createServerFn } from "@tanstack/react-start";import type { Category } from "@repo/shared/types";// createServerFn keeps this on the server, so the API key is never bundled.export const getFeaturedCategories = createServerFn({ method: "GET" }).validator((limit: number) => limit).handler(async ({ data: limit }) => {const res = await fetch(`${process.env.API_URL}/api/v1/public/categories/featured?limit=${limit}`,{ headers: { "X-API-Key": process.env.GRIT_API_KEY! } },);if (!res.ok) throw new Error(`featured failed: ${res.status}`);const body = await res.json();return body.data as Category[];});
import { createFileRoute } from "@tanstack/react-router";import { getFeaturedCategories } from "@/lib/catalog";export const Route = createFileRoute("/")({// The loader runs before the component renders, so there is no spinner and// no layout shift on first paint.loader: () => getFeaturedCategories({ data: 8 }),component: Home,});function Home() {const categories = Route.useLoaderData();return (<section className="grid grid-cols-2 md:grid-cols-4 gap-4">{categories.map((c) => (<a key={c.id} href={`/categories/${c.slug}`}>{c.image && <img src={c.image.url} alt={c.name} className="rounded-lg" />}<h3 className="mt-2 font-medium">{c.name}</h3></a>))}</section>);}
import { useMutation } from "@tanstack/react-query";import { useState } from "react";import { apiClient } from "@/lib/api";// Identical to the Next.js version. Both apps use React Query and the same// generated apiClient, so anything you write for one works in the other.export function LikeButton({ campaignId, initialLikes, initialLiked }: {campaignId: string; initialLikes: number; initialLiked: boolean;}) {const [likes, setLikes] = useState(initialLikes);const [liked, setLiked] = useState(initialLiked);const toggle = useMutation({mutationFn: async () => {const { data } = liked? await apiClient.delete(`/api/campaigns/${campaignId}/like`): await apiClient.post(`/api/campaigns/${campaignId}/like`);return data.data as { likes: number; liked: boolean };},onSuccess: (d) => { setLikes(d.likes); setLiked(d.liked); },});return (<button onClick={() => toggle.mutate()} disabled={toggle.isPending} aria-pressed={liked}>{liked ? "♥" : "♡"} {likes}</button>);}
Best practices, and the mistakes to skip
Put queries in services, not handlers
A handler is unreachable from a job, a command or a test without faking an HTTP request. The rule of thumb: if a handler contains the word Where, the logic is in the wrong file.
Return errors, not status codes, from services
The service says ErrNotFound; the handler decides that is a 404. Keeps the same method usable from a cron job.
Never interpolate user input into SQL
Always ?. Column names cannot be parameters, so if one has to come from a request, check it against a whitelist first. Grit’s generated handlers do exactly this for sort_by.
Cap anything a caller can size
limit, page_size, and the number of ids in a bulk request. ?limit=100000 on a public endpoint is free for the caller and expensive for you.
Respect archived_at in custom queries
The generated endpoints hide archived rows. A custom query that forgets shows the storefront things somebody put away on purpose.
Wrap multi-write operations in a transaction
db.Transaction(func(tx *gorm.DB) error {...}) and use tx inside. Returning an error rolls back everything.
Take the user id from the token, never the body
c.GetString("user_id") came from a signature this server checked. A user_id field in JSON came from the caller.
Make write endpoints idempotent where you can
Liking twice should leave one like, not error. Buttons get double-clicked and networks retry.
Three that cost an afternoon
/campaigns/:key, your custom route on the same segment must also say :key. Using :slug panics the router at boot with a message about conflicting wildcards, and reading c.Param("slug") when the route said :key silently gives you an empty string and a 404 you cannot explain.products but the JSON key is product_ids, singularised, and it holds ids rather than objects. POST, PUT and PATCH all accept it, and all three replace the whole set rather than adding to it.internal/handlers is package handlers, so a helper called fail that already exists in another file is a compile error, not a shadow. That is the good outcome: the alternative is two error formats in one API.