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

LayerTechnologyReason
FrontendNext.js 14App Router, RSC, rich ecosystem
BackendGo + EchoSimple, fast, type-safe, easy deployment
DBPostgreSQLMulti-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:

  1. Clear permission boundaries: System API is admin-only, Provider API is provider-only
  2. Independent deployability: Update Provider API without affecting others
  3. 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

1
2
3
packages:
  - "apps/*"
  - "packages/*"

Simple. Everything under apps/ and packages/ becomes a workspace.

turbo.json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "$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
  • dev has cache: false — Dev server shouldn’t be cached
  • type-check depends on ^build — Type packages must build first

Common Commands

1
2
3
4
5
6
7
8
# 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 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:

ConditionLocation
Same implementation in 2+ portalspackages/ui/
Single portal onlyapps/[portal]/src/components/
Contains portal-specific business logicKeep in each app

@repo/api-client — API Client + React Query

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 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")
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 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

1
2
3
4
5
6
7
8
// 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:

  1. OpenAPI spec is the source of truth
  2. TypeScript types generated via openapi-typescript
  3. Go types are manually aligned (planning oapi-codegen integration)
1
2
3
4
# 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

1
2
# Unified script starts all services
./scripts/start-dev.sh

Internally:

  1. Start Docker (PostgreSQL, Keycloak, Mailpit)
  2. Start 4 Go APIs (hot reload via air)
  3. Start Next.js apps as needed
1
2
3
# 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 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

PracticeBenefit
Shared packagesDon’t write the same code in 4 places
TurborepoBuild caching for speed
E2E testsAutomatic regression detection
Unified scriptsNo need to memorize startup procedures

What We Avoid

AvoidedReason
Microservices4 APIs is already complex enough
Over-abstractionOnly abstract after 3+ duplications
Auto type syncHigh setup cost, E2E + future CI checks suffice

Summary

ComponentTechnologyKey Point
Workspacepnpm workspaceSimple apps/ + packages/ structure
BuildTurborepodependsOn manages dependency order
Shared types@repo/typesType sharing across all apps
Shared UI@repo/uiComponents used in 2+ portals
API client@repo/api-clientShared React Query hooks
Auth@repo/authShared NextAuth configuration
BackendGo + Echo4 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