All posts
Tutorial · July 27, 2026 · 12 min read

Build an invoice app with Grit in ten minutes

A hands-on build that uses every one of Grit's new pieces in context — a Cloudflare-style theme, belongs_to and line-item relations, dropdown and toggle fields that carry their own options, atomic invoice numbering, the column you forgot added in place, and a Print button that was already there. One resource at a time, one command each, with a link to the docs for every step.

By Muke JohnBaptist
Build an invoice app with Grit in ten minutes

Feature lists are easy to skim and hard to remember. So instead of listing what's new in Grit, let's build something with it — a small but real invoice app — and pick up every new piece along the way. By the end you'll have customers, invoices with line items, auto-generated invoice numbers, a status dropdown, and a printable invoice, and you'll have touched relations, the new field types (including the one-modifier auto invoice number), grit g field, and the print view without any of them feeling like a detour.

Ten minutes. One command per step. Each step links to the docs that explain it in full.

Step 1 — Scaffold, with a theme that fits

An invoicing tool is a business app, so let's give it a business look. Grit ships three themes; pulse is the Cloudflare-inspired one — confident blue CTAs, cool grey-blue canvas, white elevated cards. Pick it right at scaffold time:

grit new invoicer --triple --next --theme pulse
cd invoicer

That's a Turborepo with a Go API, a Next.js web app, and an admin panel, all sharing one set of types and Zod schemas. (aurora is the Apple-style black/white/grey theme if you'd rather; atlas is the default.)

📖 Docs: Creating a project · Architecture modes

Step 2 — Customers, so invoices have someone to belong to

Every invoice needs a customer. Generate the resource first — it's the parent side of our first relation:

grit g resource Customer --fields "name:string,email:string,company:string"

One command, and you have the Go model, service, and handler; the REST routes; the Zod schema and TypeScript types in the shared package; the React Query hooks; and a fully working admin page with a searchable, sortable table and a form. Nothing to wire.

📖 Docs: Code generation — what the eight generated files are and how to customize them.

Step 3 — The invoice: two relations and two choice fields

This is the heart of the app, so let's slow down. One generate resource call models an invoice that belongs to a customer, has many line items, and uses the new option-backed field types for its status and a flag:

grit g resource Invoice --fields \
"number:string:auto:INV,\
status:select:draft=Draft|sent=Sent|paid=Paid,\
sent:toggle,\
customer:belongs_to:Customer" \
--items "InvoiceItem:description:string,qty:int,unit_rate:float"

What is number:string:auto:INV? It's the invoice number, auto-generated. Read it as "a string column named number, auto-numbered with the prefix INV." That one modifier stands up an atomic counter, fills the field on the server as INV-202607-0001, marks the column optional, and hides it from the form — so the user never types or even sees an empty number box. We unpack exactly what it does in Step 4.

That's a lot on one line, so here's each piece, on its own.

The two relations

customer:belongs_to:Customer is the to-one side. The invoice gets a customer_id foreign key, and — the part you'd otherwise hand-build — the admin form renders a searchable customer picker instead of a raw ID box, backed by a live query against /api/customers.

--items "InvoiceItem:description:string,qty:int,unit_rate:float" is the to-many side. It does three things in one flag: generates the whole InvoiceItem resource, gives it a belongs_to back to the invoice, and renders it as an inline, add/remove line-items table right inside the invoice form. When you save the invoice, its rows are written in the same database transaction — so an invoice and its items are always consistent.

📖 Docs: Relationships in the admin · Invoices & line items (the full --items breakdown, including how to do it as two separate commands).

The two choice fields — and how they render

status and sent use Grit's new option-backed field types. There are four, and the whole point is that you declare the choices in the field spec and Grit generates the control, the validation, and the types to match. Here's each one:

You writeRenders in the form asStored asTypes generated
status:select:draft=Draft|paid=Paida dropdown (<select>)Go stringZod z.enum([...]), TS "draft" | "paid"
rating:radio:low=Low|high=Higha radio-button group (single choice)Go stringZod z.enum([...]), TS "low" | "high"
channels:check:email=Email|sms=SMSa checkbox group (multi-select)Go datatypes.JSONSlice[string] (JSON array)Zod z.array(z.enum([...])), TS ("email" | "sms")[]
sent:togglea switchGo boolZod z.boolean(), TS boolean

So in our invoice, status:select:… becomes a dropdown with Draft / Sent / Paid, stored as a string and validated against exactly those three values everywhere — the Go binding, the Zod schema, and the TypeScript union all agree because they're generated from the same token. And sent:toggle is a simple on/off switch backed by a boolean.

select and radio are both single-choice from a fixed list and generate the exact same Go/Zod/TS — the only difference is the control. select is a compact dropdown; radio lays every option out as a button. Reach for radio when there are just a few choices you want visible at once (a plan tier, a priority), and select when the list is longer or space is tight.

Want multiple choices instead of one? That's check — it renders a checkbox group and stores the ticked values as a JSON array. For example, if invoices could be delivered several ways: channels:check:email=Email|sms=SMS|push=Push.

Labels are optional

The syntax is type:value=Label|value=Label, but the =Label part is optional. Give just the values and Grit generates the labels for you by capitalizing each one:

# These two are equivalent:
status:select:draft=Draft|sent=Sent|paid=Paid
status:select:draft|sent|paid # labels auto-generated: Draft, Sent, Paid

Multi-word values are humanized, not just capitalized — in_progress becomes In Progress, awaiting_payment becomes Awaiting Payment. You only reach for value=Label when the label needs to differ from the stored value (say net_30=Net 30, or a value that's an abbreviation you want spelled out). Mix and match freely: status:select:draft|sent=Sent to client|paid works.

📖 Docs: Field types — the full table mapping every type to its Go, GORM, TypeScript, Zod, and form representation.

The same shape, four other apps

An invoice is just one instance of a parent with a lifecycle status and a table of child rows. Once you see the shape, it's everywhere. Here are four other apps, each one command, each leaning on the new field types the same way:

E-commerce — orders and line items. A select for the order lifecycle, a radio for the two shipping options, line items as order lines.

grit g resource Order --fields \
"number:string,\
status:select:pending|paid|shipped|delivered,\
fulfillment:radio:standard|express,\
customer:belongs_to:Customer" \
--items "OrderLine:product:string,qty:int,unit_price:float"

Loan management — loans and repayments. A select for the loan state, a radio for the risk band, and each repayment carries a date and a toggle.

grit g resource Loan --fields \
"reference:string,\
status:select:pending|active|closed|defaulted,\
risk:radio:low|medium|high,\
borrower:belongs_to:Customer" \
--items "Repayment:due_date:date,amount:float,paid:toggle"

Subscriptions — plans and line charges. Two selects (the plan and the billing state) and an auto_renew toggle; line charges are the children.

grit g resource Subscription --fields \
"plan:select:starter|pro|enterprise,\
status:select:trialing|active|past_due|canceled,\
auto_renew:toggle,\
customer:belongs_to:Customer" \
--items "LineCharge:description:string,qty:int,unit_price:float"

Clinic — prescriptions and medications. A select for the prescription status, a radio for priority, and the prescribed drugs as line items.

grit g resource Prescription --fields \
"code:string,\
status:select:draft|issued|dispensed,\
priority:radio:routine|urgent,\
patient:belongs_to:Customer" \
--items "Medication:name:string,dose:string,quantity:int"

Notice none of these spell out a single label — the bare select/radio values get capitalized automatically (past_duePast Due). Swap the nouns, keep the moves.

Step 4 — Auto-number the invoices

Nobody should type INV-0001 by hand, and two people creating invoices at the same moment must never collide. Here's the surprise: we already did this in Step 3. The number:string:auto:INV field is the entire feature. There's no second command, no hook to write. Let's unpack what that one modifier bought us.

What auto generated for you. Declaring the field with :auto made Grit:

  1. Stand up an atomic counter. It wrote internal/sequence/ — a generic counter package whose count lives in a database row that's locked and incremented in a transaction, which is what makes it atomic and gap-free — and registered its table with AutoMigrate. (First auto field in the project only; later ones reuse it.)

  2. Fill the field in BeforeCreate. The generated model assigns the next number on insert, and only when one isn't already set, so an imported invoice keeps its original:

    // apps/api/internal/models/invoice.go — generated for you
    func (m *Invoice) BeforeCreate(tx *gorm.DB) error {
    if m.ID == "" {
    m.ID = uuid.New().String()
    }
    if m.Number == "" {
    n, err := sequence.Next(tx, sequence.Config{
    Name: "invoice_number", Prefix: "INV", Reset: sequence.ResetMonthly, Width: 4,
    }, time.Now())
    if err != nil {
    return err
    }
    m.Number = n
    }
    return nil
    }
  3. Make the column optional and hide it from the form. The API never demands a number, and the create/edit form has no Number box at all — it still shows on the table and detail page.

So what does the user see? They fill in the customer, status, and line items, hit Save, and the invoice comes back numbered INV-202607-0001. The number was assigned on the server, at create time — never typed, never even shown as an empty field. The counter increments in the same transaction as the insert, so it's safe under concurrent load: no duplicates, no gaps.

Why is the sequence name "invoice_number" and not "invoice"? Each auto field gets its own counter keyed by <model>_<field>, so an invoice could have a separate auto reference number on its own series without the two colliding.

When you need more control: grit generate sequence. auto is a shortcut over a lower-level command, and it makes one choice for you — monthly reset, 4-digit width. Want a yearly reset, no reset, or a different width? Or to number an existing column, or call the counter from your own handler code? Generate the sequence explicitly instead:

grit generate sequence Invoice --prefix INV --reset yearly --width 6

This writes the same counter package plus a typed helper, services.NextInvoiceNumber(db, t), that you call yourself — from a handler (where importing services is fine), or by hand-writing the same BeforeCreate hook auto generates. One rule if you call it from a model: use the generic sequence.Next directly, never the services.NextInvoiceNumber wrapper — the wrapper imports models, so calling it from a model is a models → services → models import cycle that won't compile. The sequence package imports no models, so a model can always call it. --reset never drops the date segment entirely for one continuous series (INV-000001, INV-000002, …), and if you want a shape like 2026/Q3/0001, the helper is plain Go you can edit.

📖 Docs: Invoices & line items → Auto-numbering · Field types → Auto-number

Step 5 — The column you forgot

You build for ten minutes and realize invoices need a due date. In most generators that means regenerating (and clobbering the auto-number BeforeCreate hook) or hand-editing five files. In Grit it's one command that adds the column in place:

grit g field Invoice due_date:date

It injects the field into the Go model, the create and update Zod schemas, the TypeScript type, and the admin form and table — at structural anchors, so your BeforeCreate edit is untouched. There's no migration file to manage; the model is the source of truth, so the database column appears on the next migrate:

~ *models.Invoice — added 1 column(s): due_date
Migration done — 0 table(s) created, 1 altered (+1 column(s))

The same command adds a dropdown just as easily, labels-optional and all: grit g field Invoice terms:select:net_15=Net 15|net_30=Net 30.

📖 Docs: grit g field · Migrations

Step 6 — Run it, and print an invoice

Bring the schema up and start everything:

grit migrate
grit dev

Open the admin panel, add a customer, then create an invoice: pick the customer from the searchable dropdown, choose a status from the select, flip the sent switch, add a couple of line items in the inline table, and save. You never touch the number — it fills itself in on save (Step 4).

Where does the line-item total come from?

As you type quantities and rates, the inline table shows a per-row Total and a grand total at the bottom — and you didn't ask for either. That's a small convenience, so it's worth knowing exactly how it works, because it's driven by column names, not magic:

  • The line-items table looks for one column whose name matches a quantity pattern (qty or quantity) and one that matches a money pattern (unit_rate, unit_price, rate, price, or amount).
  • If it finds both, it shows Total = quantity × money per row, and sums them. Our qty:int and unit_rate:float match, so you get it for free.

Two things follow from that. First, the total is display-only — it's computed in the browser to help data entry; only your declared columns (description, qty, unit_rate) are actually submitted and stored. If you need a persisted total, add an amount column and compute it in the item's BeforeSave hook — the same pattern as the invoice number. Second, because the trigger is the column name, if you rename qty to count or unit_rate to cost, the auto-total quietly stops appearing — rename them back to a name in those patterns (or keep qty/unit_rate) and it returns. Nothing breaks either way; you just gain or lose the convenience column.

Now open that invoice's detail page and hit Print. Every generated resource detail page ships with the button, and a print stylesheet does the rest: the detail content lives in a #print-area, and the sidebar, navbar, Edit/Delete controls, and unrelated tables are all marked no-print. What reaches the paper is exactly the invoice and its line items — customer, status, dates, the itemized table — with no per-resource template to write.

📖 Docs: Migrations · Invoices & line items → Printing

What you actually built

Ten minutes, six commands, and you have:

  • Customers and invoices with a belongs_to relation and a searchable picker.
  • Line items as an inline, atomically-saved table (--items).
  • A status dropdown and a switch, with their options — labels optional — generated across Go, Zod, and TypeScript so they can't disagree.
  • Auto-numbered invoices that are safe under concurrent load.
  • A due date you added after the fact without regenerating anything.
  • A printable invoice, for free.

And "Invoice" is just the example — the exact same moves build orders and order-items, subscriptions and line charges, or purchase-orders and receipts. Swap the nouns; the commands don't change.

grit update # get the latest, then build your own
JB — Creator of Grit
Written & published by
JB — Creator of Grit

Founder of Grit and author of The Daily Grit — a 5-minute morning read on building full-stack apps with Go + React.

Subscribe

Build it with Grit

Go + React, batteries included. Scaffold a production-ready app in one command.

Get started