Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 2]

Karan Kashyap
July 2, 2026
![Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 2]](/_next/image/?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3e1sexdu%2Fproduction%2Feeb1314f51d4c39e5d1e176c2c837de8f33725ca-1600x739.png%3Frect%3D246%2C0%2C1109%2C739%26w%3D1200%26h%3D800%26q%3D85%26fit%3Dcrop%26auto%3Dformat&w=3840&q=75)
Part 2: Data Layer & Auth
Every uploaded design needs two things before a single pixel gets processed: a row in the database and an owner. This part covers both — the Postgres schema that everything else hangs off of, and the Clerk auth layer that decides whose row it is.
The interesting part isn't the happy path. It's that this API needs to run two ways: strictly verified in production, and frictionless on a laptop with no Clerk account configured at all. I'll show how that split actually works.
Migrations: one tool, plain SQL
Schema changes live as numbered SQL files under infra/migrations/, applied with golang-migrate. No ORM-managed schema, no magic — just SQL you can read top to bottom:
1infra/migrations/2 000001_init_extensions.up.sql / .down.sql3 000002_create_enums.up.sql / .down.sql4 000003_create_users.up.sql / .down.sql5 000004_create_designs.up.sql / .down.sql6 000005_create_jobs.up.sql / .down.sql
Each pair runs through two make targets that just shell out to the migrate CLI:
1# Makefile2migrate-up:3 @echo "Set DATABASE_URL_DIRECT before running"4 migrate -path infra/migrations -database "$(DATABASE_URL_DIRECT)" up56migrate-down:7 migrate -path infra/migrations -database "$(DATABASE_URL_DIRECT)" down 1
DATABASE_URL_DIRECT — not the pooled connection — because golang-migrate needs a direct session to hold advisory locks while it runs.
Extensions and enums first
Migration 000001 turns on pgvector before anything references it, since Postgres extensions have to exist before a column can use their types:
1-- infra/migrations/000001_init_extensions.up.sql2CREATE EXTENSION IF NOT EXISTS vector;
Migration 000002 declares the three enum types every other table will reference — status values as a real Postgres type instead of loose strings, so an invalid status is a constraint violation, not a runtime bug:
1-- infra/migrations/000002_create_enums.up.sql2CREATE TYPE design_status AS ENUM ('uploaded', 'processing', 'ready', 'failed');3CREATE TYPE job_type AS ENUM ('process', 'reprocess');4CREATE TYPE job_state AS ENUM ('queued', 'running', 'succeeded', 'failed');
The schema: users → designs → jobs
Three tables carry the whole system. users mirrors Clerk identities, designs is the wide table holding everything about one sticker (asset URLs, dimensions, the print-readiness report, and its search embedding), and jobs tracks each async processing attempt against a design.
1-- infra/migrations/000004_create_designs.up.sql (excerpt)2CREATE TABLE designs (3 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),4 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,5 status design_status NOT NULL DEFAULT 'uploaded',6 original_url TEXT NOT NULL,7 cutout_url TEXT,8 contour_svg_url TEXT,9 dpi REAL,10 physical_w_mm REAL,11 physical_h_mm REAL,12 confidence REAL,13 readiness JSONB,14 embedding vector(768),15 is_public BOOLEAN NOT NULL DEFAULT FALSE,16 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),17 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()18);1920CREATE INDEX designs_public_created ON designs (is_public, created_at DESC);2122CREATE INDEX designs_embedding_hnsw ON designs23 USING hnsw (embedding vector_cosine_ops)24 WITH (m = 16, ef_construction = 64);
hnsw over ivfflat because the gallery is small — hnsw gives better recall on a small dataset and doesn't need a training pass before it's usable. A trigger keeps updated_at current on every write, so callers never set it by hand:
1CREATE OR REPLACE FUNCTION set_updated_at()2RETURNS TRIGGER AS $$3BEGIN4 NEW.updated_at = NOW();5 RETURN NEW;6END;7$$ LANGUAGE plpgsql;89CREATE TRIGGER designs_updated_at10 BEFORE UPDATE ON designs11 FOR EACH ROW EXECUTE FUNCTION set_updated_at();
jobs is deliberately thin — it doesn't duplicate anything from designs, it just tracks one processing attempt:
1-- infra/migrations/000005_create_jobs.up.sql2CREATE TABLE jobs (3 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),4 design_id UUID NOT NULL REFERENCES designs(id) ON DELETE CASCADE,5 type job_type NOT NULL,6 state job_state NOT NULL DEFAULT 'queued',7 attempts INTEGER NOT NULL DEFAULT 0,8 error TEXT,9 started_at TIMESTAMPTZ,10 finished_at TIMESTAMPTZ,11 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()12);1314CREATE INDEX jobs_design_id ON jobs (design_id);15CREATE INDEX jobs_state ON jobs (state);
Every design gets one jobs row for its initial process and another for each reprocess — that history is what the queue (Part 4) and the live status subscription (Part 5) both read from.
Type-safe queries with sqlc
Hand-written SQL, generated Go structs — sqlc reads the migrations as the schema and the .sql files under queries/ as the API surface, then generates typed methods, no runtime reflection involved:
1# services/api/db/sqlc.yaml2version: "2"3sql:4 - engine: "postgresql"5 queries: "queries/"6 schema: "../../../infra/migrations/"7 gen:8 go:9 package: "dbsqlc"10 out: "sqlc/"11 sql_package: "pgx/v5"12 overrides:13 - db_type: "uuid"14 go_type: "github.com/google/uuid.UUID"15 - db_type: "vector"16 go_type:17 import: "github.com/pgvector/pgvector-go"18 type: "Vector"
The vector override matters — without it, sqlc has no idea what Go type a pgvector column should map to and codegen fails. A query file is just SQL with a name comment above it:
1-- services/api/db/queries/users.sql2-- name: GetUserByClerkID :one3SELECT * FROM users WHERE clerk_id = $1;45-- name: UpsertUser :one6INSERT INTO users (clerk_id, email, handle)7VALUES ($1, $2, $3)8ON CONFLICT (clerk_id) DO UPDATE9 SET email = EXCLUDED.email10RETURNING *;
sqlc generate turns that into Queries.GetUserByClerkID(ctx, clerkID) and Queries.UpsertUser(ctx, ...) — real Go methods with real Go types, checked at compile time against the actual schema.
Clerk: optional, not required
The API supports running with zero Clerk configuration — no account, no keys — because local development shouldn't be blocked on an external service. Whether Clerk is even initialized comes down to one env var:
1// services/api/main.go (excerpt)2// Clerk is optional in development: when CLERK_SECRET_KEY is unset, the auth3// middleware is skipped and resolvers fall back to DEV_USER_ID.4clerkSecret := os.Getenv("CLERK_SECRET_KEY")5var clerkClient clerk.Client6if clerkSecret == "" {7 log.Println("WARN: CLERK_SECRET_KEY not set; auth middleware disabled (dev mode)")8} else {9 c, clerkErr := clerk.NewClient(clerkSecret)10 if clerkErr != nil {11 log.Fatalf("failed to create Clerk client: %v", clerkErr)12 }13 clerkClient = c14}
When Clerk is configured, the middleware itself still branches on environment — full signature verification in production, a cheap unverified claim extraction in development:
1// services/api/internal/auth/middleware.go2func Middleware(clerkClient clerk.Client) func(http.Handler) http.Handler {3 if os.Getenv("ENVIRONMENT") != "production" {4 return nonBlockingJWTMiddleware5 }6 return clerk.WithSessionV2(clerkClient)7}
nonBlockingJWTMiddleware decodes the JWT payload to pull the sub claim without checking the signature, and lets the request through either way — auth is enforced later, not at the edge:
1func nonBlockingJWTMiddleware(next http.Handler) http.Handler {2 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {3 token := ExtractBearerToken(r)4 if token != "" {5 if sub := subFromJWT(token); sub != "" {6 ctx := context.WithValue(r.Context(), UserIDKey, sub)7 r = r.WithContext(ctx)8 }9 }10 next.ServeHTTP(w, r)11 })12}
Enforcing auth where it counts: the resolver
Every mutation resolver calls requireUser(ctx) first. It reads whatever identity the middleware attached, and in development it auto-upserts a users row on the fly — because Clerk's user.created webhook can't reach localhost, so nothing else would ever create that row locally:
1// services/api/graph/resolver/helpers.go (excerpt)2func (r *Resolver) requireUser(ctx context.Context) (*dbsqlc.User, error) {3 isProd := os.Getenv("ENVIRONMENT") == "production"4 clerkID := auth.UserIDFromContext(ctx)5 devUserID := os.Getenv("DEV_USER_ID")67 if clerkID == "" {8 if !isProd && devUserID != "" {9 clerkID = devUserID10 } else {11 return nil, fmt.Errorf("authentication required")12 }13 }1415 user, err := r.Queries.GetUserByClerkID(ctx, clerkID)16 if err == nil {17 return user, nil18 }1920 if !isProd {21 // Auto-upsert: Clerk webhooks can't deliver to localhost22 email := clerkID + "@dev.local"23 if user, err = r.Queries.UpsertUser(ctx, clerkID, email, nil); err == nil {24 return user, nil25 }26 }2728 return nil, fmt.Errorf("user not found")29}
In production there's no auto-upsert — a request either has a valid Clerk session backed by a synced users row, or it's rejected. That row gets created by the webhook, not by a resolver.
Keeping users in sync from Clerk
Clerk pushes user.created/user.updated events to a webhook endpoint, verified with a Svix signature so the API knows the payload actually came from Clerk:
1// services/api/internal/auth/webhook.go (excerpt)2func VerifyWebhook(r *http.Request) (*ClerkWebhookEvent, bool) {3 secret := os.Getenv("CLERK_WEBHOOK_SECRET")4 body, _ := io.ReadAll(r.Body)56 if secret != "" {7 wh, err := svix.NewWebhook(secret)8 if err != nil || wh.Verify(body, r.Header) != nil {9 return nil, false10 }11 }1213 var event ClerkWebhookEvent14 if err := json.Unmarshal(body, &event); err != nil {15 return nil, false16 }17 return &event, true18}
The handler itself just upserts on user.created/user.updated:
1// services/api/main.go (excerpt)2mux.HandleFunc("/webhooks/clerk", func(w http.ResponseWriter, r *http.Request) {3 event, ok := auth.VerifyWebhook(r)4 if !ok {5 http.Error(w, `{"error":"invalid webhook"}`, http.StatusBadRequest)6 return7 }8 switch event.Type {9 case "user.created", "user.updated":10 var userData auth.ClerkUserData11 json.Unmarshal(event.Data, &userData)12 queries.UpsertUser(r.Context(), userData.ID, userData.EmailAddresses[0].EmailAddress, userData.Username)13 }14 w.WriteHeader(http.StatusOK)15})
Web side: mount Clerk only if it's configured
The same "optional" philosophy carries into the Next.js app — <ClerkProvider> only wraps the tree when a publishable key is actually present, so the app still boots for a contributor who hasn't set up a Clerk account yet:
1// apps/web/src/app/layout.tsx2const clerkEnabled = !!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;34export default function RootLayout({ children }: { children: React.ReactNode }) {5 const body = (6 <html lang="en">7 <body className="min-h-screen bg-background font-sans antialiased">8 <Providers clerkEnabled={clerkEnabled}>{children}</Providers>9 </body>10 </html>11 );12 return clerkEnabled ? <ClerkProvider>{body}</ClerkProvider> : body;13}
Verify it
Apply the migrations, seed a couple of fake designs, and confirm the auth fallback resolves an identity without a real Clerk session:
1make migrate-up2psql "$DATABASE_URL" < infra/seed.sql34# with the API running and DEV_USER_ID set in .env.local:5curl http://localhost:8080/debug
The /debug response echoes back clerk_id_in_ctx and dev_user_id, which is the fastest way to confirm the fallback path is actually resolving an identity before touching a single GraphQL mutation.
With users, designs, and jobs on disk and an identity attached to every request, there's finally something to build an API around.
Next up — Part 3: signed uploads to R2 and the GraphQL surface (gqlgen) that ties storage, auth, and the database together.
![Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 6]](/_next/image/?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2F3e1sexdu%2Fproduction%2Feeb1314f51d4c39e5d1e176c2c837de8f33725ca-1600x739.png%3Frect%3D61%2C0%2C1478%2C739%26w%3D800%26h%3D400%26q%3D85%26fit%3Dcrop%26auto%3Dformat&w=3840&q=75)