← Back to all posts
AITutorial

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

Karan Kashyap

Karan Kashyap

July 1, 2026

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

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:

pnpm-workspace.yaml
yaml
1# pnpm-workspace.yaml
2packages:
3 - 'apps/*'
4 - 'packages/*'

Turborepo sits on top and defines how tasks run across those packages — what depends on what, and what's cacheable:

turbo.json
json
1// turbo.json
2{
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": true
14 },
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:

package.json
json
1// package.json
2{
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:

tsconfig.base.json
json
1// tsconfig.base.json
2{
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": true
16 }
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:

text
text
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 types
9 ui/ Shared design tokens + shadcn components
10 jobs/ asynq job type definitions
11infra/
12 migrations/ golang-migrate SQL files
13 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:

Directory Layout

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:

go.work
bash
1// go.work
2go 1.25
3
4use (
5 ./packages/core-imaging
6 ./packages/jobs
7 ./services/api
8 ./services/worker
9)

What does go in from day one is lint config, since retrofitting linting onto four Go modules later is miserable:

.golangci.yml
yaml
1# .golangci.yml
2version: "2"
3
4linters:
5 enable:
6 - errcheck
7 - gosimple
8 - govet
9 - ineffassign
10 - staticcheck
11 - unused
12 - gofmt
13 - goimports
14
15linters-settings:
16 goimports:
17 local-prefixes: diecut
18
19issues:
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:

makefile
makefile
1# Makefile
2.PHONY: go-test go-vet go-lint go-build api worker
3
4go-test:
5 go test diecut/api/... diecut/worker/... diecut/core-imaging/...
6
7go-vet:
8 go vet diecut/api/... diecut/worker/... diecut/core-imaging/...
9
10GOLANGCI_LINT := $(shell go env GOPATH)/bin/golangci-lint
11
12go-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 ./...
17
18go-build: api worker
19
20api:
21 go build -o bin/api ./services/api/...
22
23worker:
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:

.env.example
bash
1# .env.example (excerpt)
2# ─── Database (Neon Postgres + pgvector) ─────────────────────────────────────
3DATABASE_URL=postgresql://user:password@host.neon.tech/dbname?sslmode=require
4DATABASE_URL_DIRECT=postgresql://user:password@host.neon.tech/dbname?sslmode=require
5
6# ─── Queue (Upstash Redis + asynq) ───────────────────────────────────────────
7REDIS_URL=rediss://default:password@host.upstash.io:6379
8
9# ─── Object Storage (Cloudflare R2 — S3-compatible) ──────────────────────────
10R2_ACCOUNT_ID=your-cloudflare-account-id
11R2_BUCKET=diecut-assets

.env.example is committed; .env.local (the real one, with real secrets) is not:

bash
bash
1# .gitignore (excerpt)
2.env
3.env.local
4.env.*.local
5
6# Keep .env.example tracked
7
8*.exe
9bin/
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:

.github/workflows/ci.yml
yaml
1# .github/workflows/ci.yml (excerpt)
2name: CI
3
4on:
5 push:
6 branches: [main]
7 pull_request:
8 branches: [main]
9
10jobs:
11 web:
12 name: Web (lint + typecheck + build)
13 runs-on: ubuntu-latest
14 steps:
15 - uses: actions/checkout@v4
16 - uses: pnpm/action-setup@v4
17 - uses: actions/setup-node@v4
18 with:
19 node-version: '22'
20 cache: 'pnpm'
21 - run: pnpm install --frozen-lockfile
22 - run: pnpm turbo lint typecheck build --filter=@diecut/web
23
24 go:
25 name: Go (vet + lint + test)
26 runs-on: ubuntu-latest
27 steps:
28 - uses: actions/checkout@v4
29 - uses: actions/setup-go@v5
30 with:
31 go-version: '1.26.x'
32 cache: true
33 - 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:

bash
bash
1pnpm install
2pnpm 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.

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