← Back to all posts
AITutorial

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

Karan Kashyap

Karan Kashyap

July 4, 2026

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

Part 6: The Studio, Storefront, and Shipping It

Everything so far has been backend: schema, auth, storage, the imaging core, the queue. This closing part covers what actually sits on top of it — the Studio create flow, the public storefront with AI-generated metadata and semantic search, and the CI/CD pipeline that gets all of it onto Cloud Run and Vercel. Three phases' worth of work, one post, because at some point a series has to actually ship.

The Studio: subscribing to your own upload

The create flow from Part 3 (requestUploadUrl → PUT → createDesign) ends with a design ID. The Studio page picks up from there by opening a GraphQL subscription and waiting on it — no polling, reusing the exact designStatus channel from Part 5:

apps/web/src/app/studio/page.tsx
typescript
1// apps/web/src/app/studio/page.tsx
2const waitForDesignReady = useCallback((designId: string): Promise<void> => {
3 return new Promise((resolve, reject) => {
4 let sub: { unsubscribe(): void };
5 const timeout = setTimeout(() => {
6 sub?.unsubscribe();
7 reject(new Error('Timed out waiting for processing'));
8 }, 60_000);
9
10 sub = apolloClient
11 .subscribe<{ designStatus?: DesignResult }>({
12 query: DESIGN_STATUS_SUB,
13 variables: { id: designId },
14 })
15 .subscribe({
16 next({ data }) {
17 const d = data?.designStatus;
18 if (!d) return;
19 if (d.status === 'READY') {
20 clearTimeout(timeout);
21 sub.unsubscribe();
22 setDesign(d);
23 setStatus('ready');
24 resolve();
25 } else if (d.status === 'FAILED') {
26 clearTimeout(timeout);
27 sub.unsubscribe();
28 reject(new Error('Processing failed on the server'));
29 }
30 },
31 error(err: Error) {
32 reject(new Error(err?.message ?? 'Subscription error'));
33 },
34 });
35 });
36}, []);

The pipeline only reports two terminal states over the wire — READY and FAILED — with nothing in between, so the UI can't show real per-stage progress. Rather than sit on a static spinner, a fixed list of pipeline step labels ("Removing background…", "Tracing die-cut contour…", "Checking DPI & bleed…") cycles on a timer while the subscription is pending. It's cosmetic, not truly synced to the worker, and the code says so with a plain setInterval — but it turns a silent wait into something that reads as progress.

Readiness results render as a flat map over whatever Object.entries gives back, so a new check added to ReadinessReport in Go shows up in the UI without a corresponding frontend change:

apps/web/src/app/studio/page.tsx
tsx
1// apps/web/src/app/studio/page.tsx
2{Object.entries(design.readiness).filter(([key]) => key !== '__typename').map(([key, check]) => (
3 <div key={key} className="flex items-start justify-between gap-2 text-sm">
4 <span className="font-medium capitalize">{key.replace(/([A-Z])/g, ' $1')}</span>
5 <span className="text-gray-500 ml-1">{check.message}</span>
6 <span className={`shrink-0 px-2 py-0.5 rounded-full text-xs font-semibold ${statusBadgeStyle[check.status] ?? 'bg-gray-100 text-gray-700'}`}>
7 {check.status}
8 </span>
9 </div>
10))}

The confidence banner from Part 4's TouchUpSuggested logic is a single threshold check against the same 0.7 cutoff the pipeline uses internally: design.confidence < 0.7 renders a dismissible warning, nothing more. And the "Export Print File" button doesn't call a mutation at all — it's a plain link to GET /export?designId=..., a REST endpoint on the API that 302-redirects straight to the exports/{id}.svg object the worker already wrote to R2 during Part 5's pipeline run. No reason to route a redirect through GraphQL.

Gallery and storefront: two audiences, one query shape

/gallery renders two sections from two different queries — myDesigns (owner-scoped, requires auth) and publicDesigns (unauthenticated, searchable) — and /g/[id] is the single public design page both link to. The search box debounces client-side before it ever hits the network:

apps/web/src/app/gallery/page.tsx
tsx
1// apps/web/src/app/gallery/page.tsx
2useEffect(() => {
3 const timer = setTimeout(() => fetchPublicDesigns(search), 300);
4 return () => clearTimeout(timer);
5}, [search, fetchPublicDesigns]);

300ms, not instant-on-keystroke — every keystroke would otherwise fire a request that immediately gets stale as the next character lands. The public design page (/g/[id]) is a stripped-down version of the Studio's result panel: same readiness badges, same low-confidence banner, same cutout/mockup rendering, just read-only and reachable without an account. Sharing a design is sharing this URL — nothing else to configure.

AI metadata: Gemini Vision writes the listing copy

generateMetadata sends the cutout — not the original upload — to Gemini, because the cutout is what a buyer would actually see on the storefront:

services/api/internal/ai/gemini.go
go
1// services/api/internal/ai/gemini.go
2func (c *Client) GenerateMetadata(ctx context.Context, imageData []byte, contentType string) (*DesignMetadata, error) {
3 prompt := `Analyze this sticker artwork and respond with a JSON object only (no markdown, no explanation).
4The JSON must have exactly these fields:
5{
6 "title": "A concise, catchy sticker title (3-6 words)",
7 "tags": ["tag1", "tag2", "tag3", "tag4", "tag5"],
8 "description": "A short 1-2 sentence marketing description"
9}`
10 // ...build the multimodal request, extract text, parse as DesignMetadata
11}

The response is free-text from a model, not a typed API — parsing strips markdown code fences before unmarshaling, and clamps the tag list to 10 rather than trusting the model to respect "5-10" from the prompt. Immediately after metadata comes back, the resolver embeds title + tags as a single string and stores the resulting vector on the same row:

services/api/graph/resolver/schema.resolvers.go
go
1// services/api/graph/resolver/schema.resolvers.go
2embeddingText := meta.Title + " " + joinStrings(meta.Tags, " ")
3var embedding pgvector.Vector
4if embVec, embErr := r.AI.EmbedText(ctx, embeddingText); embErr != nil {
5 log.Printf("WARN: embed text failed: %v", embErr)
6} else {
7 embedding = pgvector.NewVector(embVec)
8}
9updated, err := r.Queries.UpdateDesignAIMetadata(ctx, dbsqlc.UpdateDesignAIMetadataParams{
10 ID: designID, Title: meta.Title, Description: meta.Description,
11 Tags: meta.Tags, Embedding: embedding,
12})

A failed embed call doesn't fail the mutation — the metadata still saves, just without a search vector attached, so the design is findable by browsing but not by semantic search until someone regenerates metadata. publishDesign reuses the same Gemini client for a second, unrelated purpose: a moderation pass on the cutout runs before is_public ever flips to true, and a parse failure on the moderation response defaults to safe: true rather than blocking a legitimate publish on a flaky model response.

Semantic search, with a fallback that isn't an afterthought

publicDesigns(search) doesn't assume the AI path is available — it tries vector search first and falls through to a plain SQL ILIKE if embedding fails for any reason, including simply not being configured:

services/api/graph/resolver/schema.resolvers.go
go
1// services/api/graph/resolver/schema.resolvers.go
2if search != nil && *search != "" {
3 if r.AI != nil {
4 vec, vecErr := r.AI.EmbedText(ctx, *search)
5 if vecErr == nil {
6 rows, sErr := r.Queries.SearchDesignsByVector(ctx, pgvector.NewVector(vec), lim)
7 if sErr == nil {
8 for _, row := range rows {
9 d := row.Design
10 designs = append(designs, &d)
11 }
12 }
13 }
14 }
15 if designs == nil {
16 designs, dbErr = r.Queries.ListPublicDesignsSearch(ctx, dbsqlc.ListPublicDesignsSearchParams{
17 Search: *search, Cursor: cur, Limit_: lim,
18 })
19 }
20}

designs == nil is the fallback trigger — it's true whether r.AI was never configured, the embed call errored, or the vector query itself errored, so one nil check covers three different failure modes without three different code paths. A query for "minimalist cat sticker" gets genuinely close semantic matches when GEMINI_API_KEY is set; the exact same query still returns something relevant via ILIKE when it isn't. Search never just breaks.

What's actually gated in CI

Four jobs run on every PR, and only one of them is new territory for this series — go test runs with -race and a coverage file, then a step parses go tool cover's summary line and fails the build under 60%:

.github/workflows/ci.yml
yaml
1# .github/workflows/ci.yml
2- name: go test with coverage
3 run: go test -short -race -coverprofile=coverage.out -covermode=atomic diecut/api/... diecut/worker/... diecut/core-imaging/...
4
5- name: Coverage gate (≥60%)
6 run: |
7 COVERAGE=$(go tool cover -func=coverage.out | tail -1 | awk '{print $3}' | sed 's/%//')
8 awk "BEGIN{exit ($COVERAGE < 60)}" || (echo "Coverage below 60%" && exit 1)

-short matters here — it's the same flag from Part 4's benchmark test, so the 2000×2000 performance assertion sits out of the PR loop and only the deterministic synthetic-fixture regression suite runs on every push. A separate e2e job boots the actual Next.js dev server and runs a small Playwright smoke suite against it — not full coverage, just enough to catch a build that compiles but renders a blank page:

apps/web/e2e/smoke.spec.ts
typescript
1// apps/web/e2e/smoke.spec.ts
2test.describe('Gallery smoke', () => {
3 test('gallery page loads without server error', async ({ page }) => {
4 const resp = await page.goto('/gallery');
5 expect(resp?.status()).toBeLessThan(400);
6 });
7});

Three describe blocks, one assertion each — homepage renders, sign-in page doesn't 500, gallery doesn't 500. It's deliberately shallow: the Go side carries the correctness weight (contour math, readiness thresholds, dedup keys), and this suite exists purely to catch "the app doesn't boot," which unit tests can't see.

Shipping it: containers, Cloud Run, no long-lived keys

Both Go services build from the same pattern — a golang:*-alpine builder stage that runs inside the go.work workspace, producing a static binary that lands in a distroless runtime image with no shell, no package manager, and a non-root user:

services/worker/Dockerfile
dockerfile
1# services/worker/Dockerfile
2FROM golang:1.22-alpine AS builder
3WORKDIR /workspace
4COPY go.work go.work.sum ./
5COPY services/worker/go.mod services/worker/go.sum ./services/worker/
6# ...COPY the other three modules' go.mod/go.sum, then `go work sync`
7COPY services/worker/ ./services/worker/
8WORKDIR /workspace/services/worker
9RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /worker .
10
11FROM gcr.io/distroless/static-debian12
12COPY --from=builder /worker /worker
13USER nonroot:nonroot
14ENTRYPOINT ["/worker"]

Copying each module's go.mod/go.sum before the full source, then running go work sync, is what lets Docker cache the dependency layer independently of application code — a source change doesn't force a re-download of every Go module on the next build.

The one Cloud Run setting that isn't a generic "make it fast" tuning knob is --min-instances 1 on the API service. Cloud Run's default HTTP/1.1 idle-connection handling isn't friendly to a long-lived WebSocket — a designStatus subscription sitting quietly between events looks exactly like an idle connection Cloud Run wants to reclaim, and scale-to-zero would drop it. Keeping one instance warm at all times sidesteps that entirely, at the cost of the free tier's actual "$0 when idle" property — a deliberate, named trade-off, not an oversight:

bash
bash
1gcloud run deploy diecut-api \
2 --image us-central1-docker.pkg.dev/YOUR_PROJECT/diecut/api:latest \
3 --min-instances 1 --max-instances 10 --cpu 1 --memory 512Mi \
4 --set-env-vars ENVIRONMENT=production,PORT=8080 \
5 --set-secrets "DATABASE_URL=DATABASE_URL:latest" \
6 --set-secrets "GEMINI_API_KEY=GEMINI_API_KEY:latest"
7 # ...one --set-secrets per credential, all pulled from Secret Manager, none baked into the image

CI automates exactly this sequence on every push to main — no static GCP service-account key sitting in a GitHub secret, because the deploy job authenticates over Workload Identity Federation, trading a short-lived OIDC token for GCP credentials at run time:

.github/workflows/ci.yml
yaml
1# .github/workflows/ci.yml
2- name: Authenticate to GCP
3 uses: google-github-actions/auth@v2
4 with:
5 workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
6 service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}

The whole deploy path

Deploy Path

Where this leaves it

Six parts, one working system: a monorepo that keeps a Go pipeline and a Next.js app honest against the same schema, a pure-function imaging core that turns a photo into a cuttable path in seconds, a queue and pub/sub bridge that make async processing feel synchronous, and a storefront with real semantic search sitting on top of all of it — deployed with no static cloud credentials anywhere in the pipeline. Not everything made it in: rate limiting is still a NoopLimiter waiting on a real Redis-backed implementation, and the mobile app has its own story that didn't fit here. Both are honest gaps, not hidden ones — the kind every real system ships with and keeps iterating on after the demo's over.

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 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
Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 2]
AITutorial

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

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