This article was originally published on Saru Blog.
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
packages:
- "apps/*"
- "packages/*"
Simple. Everything under apps/ and packages/ becomes a workspace.
turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {
"dependsOn": ["^lint"]
},
"type-check": {
"dependsOn": ["^build"]
}
}
}
Key points:
-
"dependsOn": ["^build"]— Build dependencies first -
devhascache: false— Dev server shouldn't be cached -
type-checkdepends on^build— Type packages must build first
Common Commands
# Build all apps
pnpm build
# Develop specific app only
pnpm dev:system # System Portal only
# Lint + type-check everything
pnpm lint && pnpm type-check
4. Shared Package Design
@repo/types — Type Definitions
// packages/types/src/product.ts
export interface Product {
id: string;
code: string;
name: string;
status: ProductStatus;
providerId: string;
// ...
}
export type ProductStatus = 'draft' | 'published' | 'archived' | 'discontinued';
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
// packages/ui/src/components/ProductStatusBadge.tsx
import { ProductStatus } from '@repo/types';
const statusConfig = {
draft: { label: 'Draft', variant: 'secondary' },
published: { label: 'Published', variant: 'success' },
archived: { label: 'Archived', variant: 'muted' },
discontinued: { label: 'Discontinued', variant: 'destructive' },
};
export function ProductStatusBadge({ status }: { status: ProductStatus }) {
const config = statusConfig[status];
return <Badge variant={config.variant}>{config.label}</Badge>;
}
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
// packages/api-client/src/hooks/useProducts.ts
import { useQuery, useMutation } from '@tanstack/react-query';
import type { Product } from '@repo/types';
export function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: () => apiClient.get<Product[]>('/products'),
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateProductRequest) =>
apiClient.post<Product>('/products', data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] });
},
});
}
Why share hooks?
- Don't write the same API logic in each app
- Unified caching strategy
- Type-safe API calls
@repo/auth — NextAuth Configuration
// packages/auth/src/index.ts
import NextAuth from 'next-auth';
import Keycloak from 'next-auth/providers/keycloak';
export const { auth, handlers, signIn, signOut } = NextAuth({
providers: [
Keycloak({
clientId: process.env.KEYCLOAK_CLIENT_ID!,
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
issuer: process.env.KEYCLOAK_ISSUER!,
}),
],
// ...
});
All 4 portals use the same Keycloak configuration.
5. Go Backend Structure
4 API Entry Points
// backend/cmd/system-api/main.go
func main() {
e := echo.New()
// System API specific routes
systemRouter := router.NewSystemRouter(e, services)
systemRouter.RegisterRoutes()
e.Start(":8080")
}
// backend/cmd/provider-api/main.go
func main() {
e := echo.New()
// Provider API specific routes
providerRouter := router.NewProviderRouter(e, services)
providerRouter.RegisterRoutes()
e.Start(":8081")
}
Shared Logic
// backend/internal/application/product_service.go
type ProductService struct {
repo repository.ProductRepository
}
func (s *ProductService) Create(ctx context.Context, req CreateProductRequest) (*Product, error) {
// Called by both System API and Provider API
}
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)
# packages/types/package.json
"scripts": {
"generate:types": "openapi-typescript ../../specs/shared-schemas.yaml -o src/generated/shared-schemas.ts"
}
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
# Unified script starts all services
./scripts/start-dev.sh
Internally:
- Start Docker (PostgreSQL, Keycloak, Mailpit)
- Start 4 Go APIs (hot reload via air)
- Start Next.js apps as needed
# Individual startup also possible
pnpm dev:system # System Portal only
pnpm dev:provider # Provider Portal only
Required Environment Variables
Key environment variables for each portal:
# Authentication (Keycloak)
KEYCLOAK_CLIENT_ID=xxx
KEYCLOAK_CLIENT_SECRET=xxx
KEYCLOAK_ISSUER=http://localhost:8180/realms/saru
# API Connection
NEXT_PUBLIC_API_URL=http://localhost:808x
# NextAuth
NEXTAUTH_SECRET=xxx
NEXTAUTH_URL=http://localhost:300x
package.json Script Examples
// Root package.json
{
"scripts": {
"dev": "turbo run dev",
"dev:system": "turbo run dev --filter=system",
"dev:provider": "turbo run dev --filter=provider",
"build": "turbo run build",
"lint": "turbo run lint",
"type-check": "turbo run type-check"
}
}
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)
Top comments (0)