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

Karan Kashyap
July 2, 2026
![Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 3]](/_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 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:
1// services/api/internal/storage/r2.go2func (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.Minute9 })10 if err != nil {11 return nil, fmt.Errorf("presign PUT: %w", err)12 }13 // ...build headers map from req.SignedHeader14 return &PresignPutResult{URL: req.URL, Headers: headers, Key: key}, nil15}
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:
1// services/api/internal/storage/r2.go2func 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:
1// services/api/internal/storage/r2.go2func (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 err6 }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 err14 }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:
1// services/api/internal/storage/validate.go2const MaxUploadBytes = 10 * 1024 * 102434var AllowedContentTypes = map[string]bool{5 "image/png": true, "image/jpeg": true, "image/webp": true,6}78func 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 nil14}
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:
1# packages/graphql/schema.graphql2type UploadTarget {3 url: String!4 fields: [KeyValue!]5 headers: [KeyValue!]6 key: String!7}89input CreateDesignInput {10 storageKey: String!11 filename: String!12 physicalWMm: Float13 physicalHMm: Float14}1516type 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}2425type Query {26 design(id: ID!): Design27 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:
1// services/api/graph/resolver/schema.resolvers.go2func (r *mutationResolver) RequestUploadURL(ctx context.Context, filename string, contentType string) (*model.UploadTarget, error) {3 if err := storage.ValidateContentType(contentType); err != nil {4 return nil, err5 }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}, nil17}
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:
1// services/api/graph/resolver/schema.resolvers.go2func (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, err6 }7 originalURL := input.StorageKey8 if r.Storage != nil {9 if u, urlErr := r.Storage.PublicURL(ctx, input.StorageKey); urlErr == nil {10 originalURL = u11 }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 PROCESSING20 return dbDesignToModel(design), nil21}
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:
1// services/api/graph/resolver/schema.resolvers.go2func (r *mutationResolver) DeleteDesign(ctx context.Context, id string) (bool, error) {3 user, err := r.requireUser(ctx)4 if err != nil {5 return false, err6 }7 designID, err := parseUUID(id)8 if err != nil {9 return false, err10 }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, nil20}
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:
1// apps/web/src/app/studio/page.tsx2const 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;1213const 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 });1617const 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:
1// services/api/internal/ratelimit/ratelimit.go2type Limiter interface {3 Allow(ctx context.Context, key string, limit int, windowSec int) (bool, error)4}56type NoopLimiter struct{}78func (n *NoopLimiter) Allow(_ context.Context, _ string, _ int, _ int) (bool, error) {9 return true, nil10}
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
1# start the API, then open the GraphQL playground2open http://localhost:8080/playground
1mutation {2 requestUploadUrl(filename: "test.png", contentType: "image/png") {3 url4 key5 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.
![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)