← Back to all posts
AITutorial

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

Karan Kashyap

Karan Kashyap

July 2, 2026

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

Part 3: Storage & the GraphQL API

A design starts life as a file on someone's laptop. Before any pixel gets touched, two things have to happen: the file needs to land in object storage without ever passing through my API process, and there needs to be a single GraphQL surface that ties that upload to a database row, an owner, and eventually a processing job. This part covers both.

Why the file never touches my server

Routing uploads through the API means holding a multi-megabyte request in memory, then re-uploading it to storage — double the bandwidth, double the latency, and a server that has to scale with upload traffic instead of just query traffic. Direct-to-storage uploads sidestep all of that: the client asks my API for a short-lived signed URL, then talks to Cloudflare R2 directly.

I went with a presigned PUT over a presigned POST policy. A POST policy needs a multipart form with a pile of signed fields; a PUT is one URL, one set of headers, one request body. The client code is simpler and there's less to get wrong on either side:

services/api/internal/storage/r2.go
go
1// services/api/internal/storage/r2.go
2func (c *Client) PresignUpload(ctx context.Context, key, contentType string) (*PresignPutResult, error) {
3 req, err := c.presign.PresignPutObject(ctx, &s3.PutObjectInput{
4 Bucket: aws.String(c.bucket),
5 Key: aws.String(key),
6 ContentType: aws.String(contentType),
7 }, func(o *s3.PresignOptions) {
8 o.Expires = 5 * time.Minute
9 })
10 if err != nil {
11 return nil, fmt.Errorf("presign PUT: %w", err)
12 }
13 // ...build headers map from req.SignedHeader
14 return &PresignPutResult{URL: req.URL, Headers: headers, Key: key}, nil
15}

R2 speaks the S3 API, so this is the standard AWS SDK v2 presigner pointed at R2's endpoint instead of AWS's — https://{account_id}.r2.cloudflarestorage.com, path-style addressing, Region: "auto". The URL is only valid for five minutes, long enough for a client to start the PUT without leaving a permanently-open door.

One bucket, five prefixes

Every object a design ever produces lives under a predictable key, generated by small helper functions rather than hand-built strings scattered across the codebase:

services/api/internal/storage/r2.go
go
1// services/api/internal/storage/r2.go
2func KeyForOriginal(designID uuid.UUID, filename string) string {
3 return fmt.Sprintf("originals/%s/%s", designID.String(), filename)
4}
5func KeyForCutout(designID uuid.UUID) string { return fmt.Sprintf("cutouts/%s.png", designID) }
6func KeyForContour(designID uuid.UUID) string { return fmt.Sprintf("contours/%s.svg", designID) }
7func KeyForMockup(designID uuid.UUID, n int) string {
8 return fmt.Sprintf("mockups/%s/%d.png", designID, n)
9}
10func KeyForExport(designID uuid.UUID) string { return fmt.Sprintf("exports/%s.pdf", designID) }

That convention pays off the moment a design gets deleted — cleanup is just "walk these five prefixes for this ID," not a query against some separate assets table:

services/api/internal/storage/r2.go
go
1// services/api/internal/storage/r2.go
2func (c *Client) DeleteDesignObjects(ctx context.Context, designID uuid.UUID) error {
3 id := designID.String()
4 if err := c.deletePrefix(ctx, "originals/"+id+"/"); err != nil {
5 return err
6 }
7 singles := []string{
8 "cutouts/" + id + ".png",
9 "contours/" + id + ".svg",
10 "exports/" + id + ".pdf",
11 }
12 if err := c.DeleteObjects(ctx, singles); err != nil {
13 return err
14 }
15 return c.deletePrefix(ctx, "mockups/"+id+"/")
16}

Rejecting bad uploads before signing anything

The presigner has no opinion about what it's signing, so validation happens one layer up — before a URL is ever handed out:

services/api/internal/storage/validate.go
go
1// services/api/internal/storage/validate.go
2const MaxUploadBytes = 10 * 1024 * 1024
3
4var AllowedContentTypes = map[string]bool{
5 "image/png": true, "image/jpeg": true, "image/webp": true,
6}
7
8func ValidateContentType(contentType string) error {
9 ct := strings.ToLower(strings.TrimSpace(contentType))
10 if !AllowedContentTypes[ct] {
11 return fmt.Errorf("content type %q not allowed; must be image/png, image/jpeg, or image/webp", ct)
12 }
13 return nil
14}

Content type is checked server-side before signing (requestUploadUrl); size is enforced with a MaxUploadBytes ceiling used wherever a full byte count is available. A client can't get a signed URL for a .exe, and R2 itself will reject a PUT whose Content-Type header doesn't match what was signed.

The GraphQL schema

packages/graphql/schema.graphql is the single source of truth — it's what gqlgen codegens Go resolvers from on the API side, and what a separate codegen step turns into TypeScript types for the web client. One file, two consumers, no schema drift:

packages/graphql/schema.graphql
graphql
1# packages/graphql/schema.graphql
2type UploadTarget {
3 url: String!
4 fields: [KeyValue!]
5 headers: [KeyValue!]
6 key: String!
7}
8
9input CreateDesignInput {
10 storageKey: String!
11 filename: String!
12 physicalWMm: Float
13 physicalHMm: Float
14}
15
16type Mutation {
17 requestUploadUrl(filename: String!, contentType: String!): UploadTarget!
18 createDesign(input: CreateDesignInput!): Design!
19 updateDesign(id: ID!, input: UpdateDesignInput!): Design!
20 publishDesign(id: ID!, priceCents: Int): Design!
21 reprocessDesign(id: ID!, options: ContourOptions): Design!
22 deleteDesign(id: ID!): Boolean!
23}
24
25type Query {
26 design(id: ID!): Design
27 myDesigns: [Design!]!
28 publicDesigns(search: String, limit: Int = 24, cursor: String): DesignConnection!
29}

UploadTarget.fields exists purely so the type could support a POST-policy strategy later — with presigned PUT it's always null, only headers gets populated. Wiring it into gqlgen.yml maps scalar DateTime to graphql.Time and points the resolver layout at graph/resolver, one file per schema type via follow-schema.

Turning a signed URL into a database row

requestUploadUrl is deliberately thin — validate, mint a design ID, presign, return:

services/api/graph/resolver/schema.resolvers.go
go
1// services/api/graph/resolver/schema.resolvers.go
2func (r *mutationResolver) RequestUploadURL(ctx context.Context, filename string, contentType string) (*model.UploadTarget, error) {
3 if err := storage.ValidateContentType(contentType); err != nil {
4 return nil, err
5 }
6 designID := uuid.New()
7 key := storage.KeyForOriginal(designID, filename)
8 result, err := r.Storage.PresignUpload(ctx, key, contentType)
9 if err != nil {
10 return nil, fmt.Errorf("presign upload: %w", err)
11 }
12 headers := make([]*model.KeyValue, 0, len(result.Headers))
13 for k, v := range result.Headers {
14 headers = append(headers, &model.KeyValue{Key: k, Value: v})
15 }
16 return &model.UploadTarget{URL: result.URL, Headers: headers, Key: result.Key}, nil
17}

Notice the design ID is generated here, before any row exists — the storage key already encodes it. The actual designs row gets written a step later, once the file is safely in R2:

services/api/graph/resolver/schema.resolvers.go
go
1// services/api/graph/resolver/schema.resolvers.go
2func (r *mutationResolver) CreateDesign(ctx context.Context, input model.CreateDesignInput) (*model.Design, error) {
3 user, err := r.requireUser(ctx)
4 if err != nil {
5 return nil, err
6 }
7 originalURL := input.StorageKey
8 if r.Storage != nil {
9 if u, urlErr := r.Storage.PublicURL(ctx, input.StorageKey); urlErr == nil {
10 originalURL = u
11 }
12 }
13 design, err := r.Queries.CreateDesign(ctx, dbsqlc.CreateDesignParams{
14 UserID: user.ID,
15 OriginalUrl: originalURL,
16 PhysicalWMm: physW,
17 PhysicalHMm: physH,
18 })
19 // ... enqueue a processing job, flip status to PROCESSING
20 return dbDesignToModel(design), nil
21}

That enqueue call is the handoff into the async pipeline — the part of the resolver that's really just "hand this off and get out of the way." I'll pick that thread up properly in the next part.

Ownership is enforced once, in one helper

Every mutation that touches an existing design calls requireUser first, which is where the dev-vs-prod identity story from Part 2 pays off — updateDesign, publishDesign, reprocessDesign, and deleteDesign all share the same "resolve the caller, then check they own the row" shape. deleteDesign is a good example because it has to clean up two systems, not one:

services/api/graph/resolver/schema.resolvers.go
go
1// services/api/graph/resolver/schema.resolvers.go
2func (r *mutationResolver) DeleteDesign(ctx context.Context, id string) (bool, error) {
3 user, err := r.requireUser(ctx)
4 if err != nil {
5 return false, err
6 }
7 designID, err := parseUUID(id)
8 if err != nil {
9 return false, err
10 }
11 if r.Storage != nil {
12 if delErr := r.Storage.DeleteDesignObjects(ctx, designID); delErr != nil {
13 log.Printf("WARN: delete R2 objects for design %s: %v", designID, delErr)
14 }
15 }
16 if err := r.Queries.DeleteDesign(ctx, designID, user.ID); err != nil {
17 return false, fmt.Errorf("delete design: %w", err)
18 }
19 return true, nil
20}

The sqlc-generated DeleteDesign query scopes its WHERE clause to user_id, so there's no separate ownership check to forget — a delete for someone else's design simply matches zero rows.

Client side: three calls, one progress bar

The Studio upload flow on web is a straight line through the mutations above — no separate REST endpoint, no upload SDK:

apps/web/src/app/studio/page.tsx
typescript
1// apps/web/src/app/studio/page.tsx
2const uploadData = await gqlFetch(
3 `mutation RequestUploadUrl($filename: String!, $contentType: String!) {
4 requestUploadUrl(filename: $filename, contentType: $contentType) {
5 url key headers { key value }
6 }
7 }`,
8 { filename: file.name, contentType: file.type },
9 token,
10);
11const { url, key, headers: presignHeaders } = uploadData.data.requestUploadUrl;
12
13const putHeaders: Record<string, string> = { 'Content-Type': file.type };
14for (const h of presignHeaders ?? []) putHeaders[h.key] = h.value;
15await fetch(url, { method: 'PUT', headers: putHeaders, body: file });
16
17const createData = await gqlFetch(
18 `mutation CreateDesign($input: CreateDesignInput!) {
19 createDesign(input: $input) { id status mockupUrls tags }
20 }`,
21 { input: { storageKey: key, filename: file.name, physicalWMm, physicalHMm } },
22 token,
23);

Three round trips, and only the middle one carries the actual file bytes — to R2, not to my API.

Rate limiting is a real interface with a fake behind it, for now

Mutations go through a ratelimit.Limiter interface rather than a hard-coded check, because the eventual implementation needs shared state across Cloud Run instances that scale to zero — an in-process counter would reset on every cold start:

services/api/internal/ratelimit/ratelimit.go
go
1// services/api/internal/ratelimit/ratelimit.go
2type Limiter interface {
3 Allow(ctx context.Context, key string, limit int, windowSec int) (bool, error)
4}
5
6type NoopLimiter struct{}
7
8func (n *NoopLimiter) Allow(_ context.Context, _ string, _ int, _ int) (bool, error) {
9 return true, nil
10}

Right now New() always hands back the NoopLimiter — the interface is load-bearing, the Redis-backed token bucket behind it isn't wired yet. That's fine for local development and it keeps every call site honest: swapping in a real limiter later is a one-function change, not a resolver-by-resolver rewrite.

Verify it

bash
bash
1# start the API, then open the GraphQL playground
2open http://localhost:8080/playground
graphql
graphql
1mutation {
2 requestUploadUrl(filename: "test.png", contentType: "image/png") {
3 url
4 key
5 headers { key value }
6 }
7}

PUT a real PNG to the returned url with the given headers, then call createDesign with the returned key as storageKey — a myDesigns query should immediately show a design in PROCESSING status, because a job just got enqueued for it.

That queued job is where the interesting engineering actually starts.

Next up — Part 4: the core-imaging pipeline — background removal, die-cut contour extraction, and print-readiness checks running inside the worker.

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