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

Karan Kashyap
July 1, 2026
![Let's Build a Print-Ready Die-Cut Sticker SaaS from scratch in Golang & Next.js [Part 1]](/_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 1: Project Setup & Monorepo Scaffolding
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.
Get this wrong and every phase after it fights the structure. Get it right and a Go pipeline and a Next.js web app can live in one repo, share types, and ship through one CI pipeline without stepping on each other.
Why one repo
The trade-off is tooling: a bare npm install at the root doesn't know how to build four independent projects in the right order or skip the ones that haven't changed. That's what Turborepo and pnpm workspaces solve.
Root workspace config
pnpm workspaces tell the package manager which directories are independent packages sharing one node_modules:
1# pnpm-workspace.yaml2packages:3 - 'apps/*'4 - 'packages/*'
Turborepo sits on top and defines how tasks run across those packages — what depends on what, and what's cacheable:
1// turbo.json2{3 "$schema": "https://turbo.build/schema.json",4 "ui": "tui",5 "tasks": {6 "build": {7 "dependsOn": ["^build"],8 "inputs": ["$TURBO_DEFAULT$", ".env*"],9 "outputs": [".next/**", "!.next/cache/**", "dist/**"]10 },11 "dev": {12 "cache": false,13 "persistent": true14 },15 "lint": {16 "dependsOn": ["^lint"]17 },18 "typecheck": {19 "dependsOn": ["^typecheck"]20 },21 "test": {22 "dependsOn": ["^build"],23 "outputs": ["coverage/**"]24 }25 }26}
The ^build syntax means "build this package's dependencies first" — so if apps/web depends on packages/graphql, Turborepo builds the schema package before the app that consumes it.
Root package.json just delegates every script to Turborepo:
1// package.json2{3 "name": "diecut-studio",4 "private": true,5 "scripts": {6 "build": "turbo build",7 "dev": "turbo dev",8 "lint": "turbo lint",9 "typecheck": "turbo typecheck",10 "test": "turbo test",11 "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\""12 },13 "devDependencies": {14 "@typescript-eslint/eslint-plugin": "^8.19.0",15 "@typescript-eslint/parser": "^8.19.0",16 "eslint": "^9.17.0",17 "prettier": "^3.4.2",18 "turbo": "^2.3.0",19 "typescript": "^5.7.2"20 },21 "engines": {22 "node": ">=20.0.0",23 "pnpm": ">=9.0.0"24 },25 "packageManager": "pnpm@11.6.0"26}
Shared lint, format, and TypeScript config live at the root too, so every package inherits the same rules instead of redefining them:
1// tsconfig.base.json2{3 "compilerOptions": {4 "target": "ES2020",5 "lib": ["ES2020", "DOM", "DOM.Iterable"],6 "module": "ESNext",7 "moduleResolution": "bundler",8 "strict": true,9 "noUnusedLocals": true,10 "noUnusedParameters": true,11 "noFallthroughCasesInSwitch": true,12 "skipLibCheck": true,13 "esModuleInterop": true,14 "resolveJsonModule": true,15 "incremental": true16 }17}
Each app's own tsconfig.json just extends this base and adds framework-specific options later.
Directory layout
With the workspace config in place, the next commit lays down the actual folders — most of them empty placeholders at this point, since the Go services and frontend apps don't exist yet:
1apps/2 web/ Next.js Studio + Storefront (Part 6)3services/4 api/ Go gqlgen GraphQL gateway (Part 3)5 worker/ Go async image pipeline (Part 5)6packages/7 core-imaging/ Go: BG removal, contour, readiness (Part 4)8 graphql/ Shared GraphQL schema + generated TS types9 ui/ Shared design tokens + shadcn components10 jobs/ asynq job type definitions11infra/12 migrations/ golang-migrate SQL files13 cloudrun/ Cloud Run service YAML configs
Each directory starts as nothing more than a .gitkeep — the point of this commit isn't to write code, it's to lock in the shape of the system before any of it exists. Every part after this fills in one or more of these folders:
Go side: go.mod per module, go.work to tie them together locally
Go's four packages (core-imaging, jobs, api, worker) each get their own go.mod, kept independent so CI and each service's Docker build can vet/build/lint them in isolation. A root go.work ties them together for local development, so cross-module edits resolve immediately without publishing anything:
1// go.work2go 1.2534use (5 ./packages/core-imaging6 ./packages/jobs7 ./services/api8 ./services/worker9)
What does go in from day one is lint config, since retrofitting linting onto four Go modules later is miserable:
1# .golangci.yml2version: "2"34linters:5 enable:6 - errcheck7 - gosimple8 - govet9 - ineffassign10 - staticcheck11 - unused12 - gofmt13 - goimports1415linters-settings:16 goimports:17 local-prefixes: diecut1819issues:20 exclude-rules:21 - path: "_test.go"22 linters:23 - errcheck
And a Makefile with the handful of Go commands every service will share — build, vet, test, lint:
1# Makefile2.PHONY: go-test go-vet go-lint go-build api worker34go-test:5 go test diecut/api/... diecut/worker/... diecut/core-imaging/...67go-vet:8 go vet diecut/api/... diecut/worker/... diecut/core-imaging/...910GOLANGCI_LINT := $(shell go env GOPATH)/bin/golangci-lint1112go-lint:13 cd services/api && $(GOLANGCI_LINT) run ./...14 cd services/worker && $(GOLANGCI_LINT) run ./...15 cd packages/core-imaging && $(GOLANGCI_LINT) run ./...16 cd packages/jobs && $(GOLANGCI_LINT) run ./...1718go-build: api worker1920api:21 go build -o bin/api ./services/api/...2223worker:24 go build -o bin/worker ./services/worker/...
go-lint runs per module rather than once at the root — golangci-lint v2 resolves its config relative to each module's go.mod, so a single root-level invocation silently skips three of the four modules. Wrapping go build/go test in make targets means the exact same commands run locally and in CI — no drift between what a developer runs and what the pipeline runs. (The Makefile also grew a dev-up target once docker-compose.yml landed — make dev-up brings up Postgres, Redis, and MinIO locally with migrations and seed data applied automatically, no Neon, Upstash, or R2 account required. More on that when the series gets to running things end to end.)
Environment variables, documented before they're used
Every external dependency this project will eventually touch — Postgres, Redis, R2, Clerk, Gemini — gets a placeholder entry in .env.example before a single line of code calls any of them:
1# .env.example (excerpt)2# ─── Database (Neon Postgres + pgvector) ─────────────────────────────────────3DATABASE_URL=postgresql://user:password@host.neon.tech/dbname?sslmode=require4DATABASE_URL_DIRECT=postgresql://user:password@host.neon.tech/dbname?sslmode=require56# ─── Queue (Upstash Redis + asynq) ───────────────────────────────────────────7REDIS_URL=rediss://default:password@host.upstash.io:637989# ─── Object Storage (Cloudflare R2 — S3-compatible) ──────────────────────────10R2_ACCOUNT_ID=your-cloudflare-account-id11R2_BUCKET=diecut-assets
.env.example is committed; .env.local (the real one, with real secrets) is not:
1# .gitignore (excerpt)2.env3.env.local4.env.*.local56# Keep .env.example tracked78*.exe9bin/10apps/web/.next/11apps/mobile/.expo/12.turbo/
CI: green on an empty repo
The last piece of this phase is a CI pipeline that runs before there's meaningful code to test — lint, typecheck, and build jobs for the web app, a typecheck job for mobile, and vet/lint/test for the Go modules, all gated on a pull request:
1# .github/workflows/ci.yml (excerpt)2name: CI34on:5 push:6 branches: [main]7 pull_request:8 branches: [main]910jobs:11 web:12 name: Web (lint + typecheck + build)13 runs-on: ubuntu-latest14 steps:15 - uses: actions/checkout@v416 - uses: pnpm/action-setup@v417 - uses: actions/setup-node@v418 with:19 node-version: '22'20 cache: 'pnpm'21 - run: pnpm install --frozen-lockfile22 - run: pnpm turbo lint typecheck build --filter=@diecut/web2324 go:25 name: Go (vet + lint + test)26 runs-on: ubuntu-latest27 steps:28 - uses: actions/checkout@v429 - uses: actions/setup-go@v530 with:31 go-version: '1.26.x'32 cache: true33 - run: go vet diecut/api/... diecut/worker/... diecut/core-imaging/...
With this in place, opening a pull request — even one that touches nothing but a README typo — runs the full lint/typecheck/test matrix. That matters because it means the first real feature commit already has a safety net, instead of bolting one on after the codebase has grown enough to make retrofitting painful.
Run this to confirm the workspace is wired correctly before moving on:
1pnpm install2pnpm turbo build lint typecheck
With the skeleton compiling and CI green, the next thing every feature needs is somewhere to persist data.
Next up — Part 2: the data layer — designing the Postgres schema, wiring golang-migrate and sqlc, and putting Clerk auth in front of it.
![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)