DEV Community

ko-chan
ko-chan

Posted on • Originally published at ko-chan.github.io

Next.js + Go Monorepo: Managing 4 Portals 4 APIs as a Solo Developer [Part 3]

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

All 4 APIs reference the same internal/, registering only the handlers they need.

3. pnpm workspace + Turborepo

pnpm-workspace.yaml

packages:
  - "apps/*"
  - "packages/*"
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

# Build all apps
pnpm build

# Develop specific app only
pnpm dev:system    # System Portal only

# Lint + type-check everything
pnpm lint && pnpm type-check
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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'] });
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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!,
    }),
  ],
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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")
}
Enter fullscreen mode Exit fullscreen mode
// 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")
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
# packages/types/package.json
"scripts": {
  "generate:types": "openapi-typescript ../../specs/shared-schemas.yaml -o src/generated/shared-schemas.ts"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Internally:

  1. Start Docker (PostgreSQL, Keycloak, Mailpit)
  2. Start 4 Go APIs (hot reload via air)
  3. Start Next.js apps as needed
# Individual startup also possible
pnpm dev:system     # System Portal only
pnpm dev:provider   # Provider Portal only
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)