← Back to all posts
AITutorial

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

Karan Kashyap

Karan Kashyap

July 2, 2026

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

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:

text
text
1infra/migrations/
2 000001_init_extensions.up.sql / .down.sql
3 000002_create_enums.up.sql / .down.sql
4 000003_create_users.up.sql / .down.sql
5 000004_create_designs.up.sql / .down.sql
6 000005_create_jobs.up.sql / .down.sql

Each pair runs through two make targets that just shell out to the migrate CLI:

makefile
makefile
1# Makefile
2migrate-up:
3 @echo "Set DATABASE_URL_DIRECT before running"
4 migrate -path infra/migrations -database "$(DATABASE_URL_DIRECT)" up
5
6migrate-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:

infra/migrations/000001_init_extensions.up.sql
sql
1-- infra/migrations/000001_init_extensions.up.sql
2CREATE 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:

infra/migrations/000002_create_enums.up.sql
sql
1-- infra/migrations/000002_create_enums.up.sql
2CREATE 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.

Database Schema

infra/migrations/000004_create_designs.up.sql
sql
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);
19
20CREATE INDEX designs_public_created ON designs (is_public, created_at DESC);
21
22CREATE INDEX designs_embedding_hnsw ON designs
23 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:

sql
sql
1CREATE OR REPLACE FUNCTION set_updated_at()
2RETURNS TRIGGER AS $$
3BEGIN
4 NEW.updated_at = NOW();
5 RETURN NEW;
6END;
7$$ LANGUAGE plpgsql;
8
9CREATE TRIGGER designs_updated_at
10 BEFORE UPDATE ON designs
11 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:

infra/migrations/000005_create_jobs.up.sql
sql
1-- infra/migrations/000005_create_jobs.up.sql
2CREATE 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);
13
14CREATE 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:

services/api/db/sqlc.yaml
yaml
1# services/api/db/sqlc.yaml
2version: "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:

services/api/db/queries/users.sql
sql
1-- services/api/db/queries/users.sql
2-- name: GetUserByClerkID :one
3SELECT * FROM users WHERE clerk_id = $1;
4
5-- name: UpsertUser :one
6INSERT INTO users (clerk_id, email, handle)
7VALUES ($1, $2, $3)
8ON CONFLICT (clerk_id) DO UPDATE
9 SET email = EXCLUDED.email
10RETURNING *;

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:

services/api/main.go
go
1// services/api/main.go (excerpt)
2// Clerk is optional in development: when CLERK_SECRET_KEY is unset, the auth
3// middleware is skipped and resolvers fall back to DEV_USER_ID.
4clerkSecret := os.Getenv("CLERK_SECRET_KEY")
5var clerkClient clerk.Client
6if 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 = c
14}

When Clerk is configured, the middleware itself still branches on environment — full signature verification in production, a cheap unverified claim extraction in development:

services/api/internal/auth/middleware.go
go
1// services/api/internal/auth/middleware.go
2func Middleware(clerkClient clerk.Client) func(http.Handler) http.Handler {
3 if os.Getenv("ENVIRONMENT") != "production" {
4 return nonBlockingJWTMiddleware
5 }
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:

go
go
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:

services/api/graph/resolver/helpers.go
go
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")
6
7 if clerkID == "" {
8 if !isProd && devUserID != "" {
9 clerkID = devUserID
10 } else {
11 return nil, fmt.Errorf("authentication required")
12 }
13 }
14
15 user, err := r.Queries.GetUserByClerkID(ctx, clerkID)
16 if err == nil {
17 return user, nil
18 }
19
20 if !isProd {
21 // Auto-upsert: Clerk webhooks can't deliver to localhost
22 email := clerkID + "@dev.local"
23 if user, err = r.Queries.UpsertUser(ctx, clerkID, email, nil); err == nil {
24 return user, nil
25 }
26 }
27
28 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:

services/api/internal/auth/webhook.go
go
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)
5
6 if secret != "" {
7 wh, err := svix.NewWebhook(secret)
8 if err != nil || wh.Verify(body, r.Header) != nil {
9 return nil, false
10 }
11 }
12
13 var event ClerkWebhookEvent
14 if err := json.Unmarshal(body, &event); err != nil {
15 return nil, false
16 }
17 return &event, true
18}

The handler itself just upserts on user.created/user.updated:

services/api/main.go
go
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 return
7 }
8 switch event.Type {
9 case "user.created", "user.updated":
10 var userData auth.ClerkUserData
11 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:

apps/web/src/app/layout.tsx
tsx
1// apps/web/src/app/layout.tsx
2const clerkEnabled = !!process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
3
4export 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:

bash
bash
1make migrate-up
2psql "$DATABASE_URL" < infra/seed.sql
3
4# 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.

Ready to Build Something Extraordinary?

Let's discuss your idea. We'll show you how AI-powered development can compress your timeline and budget — without cutting corners.

We respond within 24 hours. No sales pitch — just a straight conversation about your project.

More from the Blog

Explore more engineering insights, case studies, and technical deep-dives.

View all posts →
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 6]
AITutorial

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

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 4, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 5]
AITutorial

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

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 3, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 4]
AITutorial

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

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 3, 2026
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 3]
AITutorial

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

DieCutGo Studio turns any uploaded artwork into a print-ready die-cut sticker — background removal, contour tracing, print-readiness checks, mockups, and a shareable storefront, all backed by a Go pipeline fast enough to feel instant. Over this series I'll walk through how the whole thing is built, starting today with the least glamorous but most consequential decision: how the repo itself is laid out.

Karan KashyapJul 2, 2026