What You’ll Learn
- Next.js + Go monorepo architecture patterns
- Practical use of pnpm workspace + Turborepo
- Sharing UI components across 4 portals
- Package splitting strategies that don’t break down in solo development
Introduction
As introduced in Part 1, Saru is a multi-tenant SaaS with a 4-tier account structure. To implement this, I adopted an architecture of 4 frontends + 4 backend APIs.
Normally, this would mean managing 8 repositories. For solo development, that would be unsustainable.
So I chose a monorepo. This article explains the architecture and design decisions.
1. Why Next.js × Go?
Technology Selection Rationale
| Layer | Technology | Reason |
|---|---|---|
| Frontend | Next.js 14 | App Router, RSC, rich ecosystem |
| Backend | Go + Echo | Simple, fast, type-safe, easy deployment |
| DB | PostgreSQL | Multi-tenant isolation via RLS |
Why not use a full-stack framework (Next.js API Routes)?
- Separation of concerns: Want independent deploy cycles for frontend and backend
- Language strengths: Go handles complex business logic better (personal opinion)
- Scalability: May want to scale APIs independently in the future
Authentication flow is divided as follows: Keycloak handles user authentication, NextAuth manages OAuth/sessions, and Go APIs validate Keycloak access tokens (JWT) for authorization.
2. Project Structure
saru/
├── apps/ # 6 Next.js apps
│ ├── system/ # System Portal (admins)
│ ├── provider/ # Provider Portal (service providers)
│ ├── reseller/ # Reseller Portal (resellers)
│ ├── consumer/ # Consumer Portal (end users)
│ ├── customer/ # Customer Portal (legacy, merging with consumer)
│ └── landing/ # Landing pages
│
├── packages/ # Shared packages
│ ├── types/ # TypeScript type definitions
│ ├── ui/ # Shared UI components
│ ├── api-client/ # API client + React Query hooks
│ ├── auth/ # NextAuth configuration
│ ├── config/ # ESLint, TypeScript config
│ └── env-validator/ # Environment variable validation
│
├── backend/ # Go backend
│ ├── cmd/
│ │ ├── system-api/ # System API (port 8080)
│ │ ├── provider-api/ # Provider API (port 8081)
│ │ ├── reseller-api/ # Reseller API (port 8082)
│ │ ├── consumer-api/ # Consumer API (port 8083)
│ │ └── migrate/ # Migration CLI
│ └── internal/ # Shared logic
│
├── e2e/ # Playwright E2E tests
├── pnpm-workspace.yaml # pnpm workspace config
└── turbo.json # Turborepo config
Why 4 Separate APIs?
You might ask: “Why not just one API for everything?”
Reasons for separation:
- Clear permission boundaries: System API is admin-only, Provider API is provider-only
- Independent deployability: Update Provider API without affecting others
- Code clarity: One API with all endpoints becomes complex
Shared logic lives in internal/:
backend/internal/
├── domain/ # Domain models
├── application/ # Use cases
├── infrastructure/ # DB, external services
└── interfaces/ # Handlers, DTOs
All 4 APIs reference the same internal/, registering only the handlers they need.
3. pnpm workspace + Turborepo
pnpm-workspace.yaml
| |
Simple. Everything under apps/ and packages/ becomes a workspace.
turbo.json
| |
Key points:
"dependsOn": ["^build"]— Build dependencies firstdevhascache: false— Dev server shouldn’t be cachedtype-checkdepends on^build— Type packages must build first
Common Commands
| |
4. Shared Package Design
@repo/types — Type Definitions
| |
Why a separate types package?
- Multiple apps use the same types
- Match backend response types
- Changes propagate to all apps
@repo/ui — Shared UI Components
| |
Sharing criteria:
| Condition | Location |
|---|---|
| Same implementation in 2+ portals | packages/ui/ |
| Single portal only | apps/[portal]/src/components/ |
| Contains portal-specific business logic | Keep in each app |
@repo/api-client — API Client + React Query
| |
Why share hooks?
- Don’t write the same API logic in each app
- Unified caching strategy
- Type-safe API calls
@repo/auth — NextAuth Configuration
| |
All 4 portals use the same Keycloak configuration.
5. Go Backend Structure
4 API Entry Points
| |
| |
Shared Logic
| |
All APIs use the same Services. The only difference is which endpoints each router registers.
Multi-Layer Authorization
Authorization check flow:
1. Go API: JWT signature verification + account type check
2. Go API: Business logic layer permission check
3. PostgreSQL RLS: Tenant isolation at data access (last line of defense)
API separation and RLS serve different purposes:
- API separation: Endpoint-level access control (who can call which functions)
- RLS: Data-level isolation (which data can be accessed)
This defense-in-depth ensures that even if API permission checks have gaps, RLS limits the blast radius (when properly configured).
6. Type Sharing: TypeScript ↔ Go
We don’t have full automatic sync. Instead:
- OpenAPI spec is the source of truth
- TypeScript types generated via
openapi-typescript - Go types are manually aligned (planning
oapi-codegenintegration)
| |
Honest truth: Full automation isn’t there yet. Go and TypeScript types can drift. E2E tests catch major flow issues, but aren’t comprehensive. Planning to add OpenAPI schema CI checks for better coverage.
7. Development Server Startup
| |
Internally:
- Start Docker (PostgreSQL, Keycloak, Mailpit)
- Start 4 Go APIs (hot reload via air)
- Start Next.js apps as needed
| |
Required Environment Variables
Key environment variables for each portal:
| |
package.json Script Examples
| |
8. Avoiding Burnout in Solo Development
What We Do
| Practice | Benefit |
|---|---|
| Shared packages | Don’t write the same code in 4 places |
| Turborepo | Build caching for speed |
| E2E tests | Automatic regression detection |
| Unified scripts | No need to memorize startup procedures |
What We Avoid
| Avoided | Reason |
|---|---|
| Microservices | 4 APIs is already complex enough |
| Over-abstraction | Only abstract after 3+ duplications |
| Auto type sync | High setup cost, E2E + future CI checks suffice |
Summary
| Component | Technology | Key Point |
|---|---|---|
| Workspace | pnpm workspace | Simple apps/ + packages/ structure |
| Build | Turborepo | dependsOn manages dependency order |
| Shared types | @repo/types | Type sharing across all apps |
| Shared UI | @repo/ui | Components used in 2+ portals |
| API client | @repo/api-client | Shared React Query hooks |
| Auth | @repo/auth | Shared NextAuth configuration |
| Backend | Go + Echo | 4 APIs, shared logic in internal/ |
Monorepos have setup costs, but once established, “add this component to that app too…” becomes trivial.
For solo developers managing multiple apps, monorepo is a strong choice.
Series Articles
- Part 1: Tackling Unmanageable Complexity with Automation
- Part 2: Testing WebAuthn in CI
- Part 3: Next.js + Go Monorepo (this article)