SPACY project
SPACY DevOps
Work

SPACY DevOps

Mar 2023
Table of Contents

Overview

The Spacy Platform (internally codenamed “Spacy Yamanashi”) was designed to solve a critical business challenge: efficiently deploying and maintaining multiple client-specific applications based on the core Spacy technology.

Instead of forking the codebase for each client (which leads to maintenance nightmares), we built a Monorepo using Turborepo. This allows us to share a massive amount of code—API definitions, database schemas, UI components, and authentication logic—across all applications while still allowing for client-specific customizations.

Tech Stack

TechnologyCategoryDescription
TurborepoMonorepoHigh-performance build system for JavaScript/TypeScript monorepos.
pnpmPackage ManagerFast, disk space efficient package manager.
GitHub ActionsCI/CDAutomated pipeline with intelligent caching.
tRPCAPIEnd-to-end typesafe APIs without schemas or code generation.
PrismaORMType-safe database client for MySQL (PlanetScale).
NextAuthAuthenticationAuthentication and authorization for Next.js applications.

Monorepo Architecture

We structured the repository to strictly separate Apps (deployable units) from Packages (shared logic).

.
├── apps/
│   ├── web/               # Next.js App for Web
│   ├── client-a/          # Next.js App for Client A
│   ├── client-b/          # Next.js App for Client B
│   ├── .../               # Next.js App for Client X
│   └── admin/             # Internal Admin Tool
└── packages/
    ├── api/               # Shared tRPC Routers & Procedures
    ├── auth/              # NextAuth.js Configuration
    ├── db/                # Prisma Schema & Client
    ├── ui/                # Shared React Components (Design System)
    ├── config/            # Shared ESLint, Tailwind, TSConfig
    └── types/             # Shared Zod Schemas & TypeScript Types

Shared Package Strategy

The power of this architecture lies in the packages directory. By treating our internal code as packages, we can import them into any app just like a third-party dependency. This approach ensures consistency, reduces duplication, and accelerates development.

How It Works: Build-Time Package Sharing

We utilize a strategy known as “Internal Packages” to share code without the overhead of building each package separately.

1. pnpm Workspaces

We configure pnpm-workspace.yaml to tell pnpm that packages/* are local workspaces. When we run pnpm install, pnpm symlinks these packages into the node_modules of our apps.

packages:
  - apps/*
  - packages/*pnpm-workspace.yaml

2. package.json Configuration

Unlike traditional npm packages that need to be bundled (e.g., to dist/index.js), our internal packages point directly to their TypeScript source.

{
  "name": "@spacy/ui",
  "main": "./index.tsx",
  "types": "./index.tsx"
}packages/ui/package.json

3. Declare Dependency

In the app’s package.json, we add the internal package as a dependency.

{
  "dependencies": {
    "@spacy/api": "^0.1.0",
    "@spacy/auth": "^0.1.0",
    "@spacy/db": "^0.1.0",
    "@spacy/ui": "^0.1.0",
    "@spacy/utils": "^0.1.0"
  },
  "devDependencies": {
    "@spacy/eslint-config": "^0.1.0",
    "@spacy/tailwind-config": "^0.1.0",
    "@spacy/type": "^0.1.0"
  }
}apps/web/package.json

4. App-Side Transpilation

Since the packages are just TypeScript files, the consuming application (Next.js) is responsible for compiling them. We configure Next.js to transpile these specific packages during its own build process.

const config = {
  reactStrictMode: true,
  /** Enables hot reloading for local packages without a build step */
  transpilePackages: [
    "@spacy/api",
    "@spacy/auth",
    "@spacy/db",
    "@spacy/ui",
    "@spacy/utils",
  ],
};apps/web/next.config.mjs

5. Turborepo Orchestration

Turborepo understands this dependency graph. If we modify a file in packages/ui, Turbo knows that apps/web depends on it and will invalidate the cache for the web app’s build, ensuring the change is reflected.

This setup gives us the best of both worlds: the modularity of packages with the development velocity of a monolith (instant feedback, no “watch mode” for packages needed).

Each Package

1. packages/api (Type-Safe API with tRPC)

This package contains the tRPC router definitions and procedures. It acts as the backend logic layer, importing the database client and authentication middleware to define API endpoints.

export { appRouter, type AppRouter } from "./src/root";
export { createTRPCContext } from "./src/trpc";packages/api/index.ts

Usage in Next.js App (apps/web/src/pages/api/trpc/[trpc].ts):

import { createNextApiHandler } from "@trpc/server/adapters/next";
import { appRouter, createTRPCContext } from "@spacy/api";

export default createNextApiHandler({
  router: appRouter,
  createContext: createTRPCContext,
});apps/web/src/pages/api/trpc/[trpc].ts

2. packages/auth (Authentication & RBAC Middleware with NextAuth.js)

This package centralizes authentication logic, including NextAuth.js configuration (providers, callbacks) and RBAC (Role-Based Access Control) middleware.

export const requireAdmin = async () => {
  const session = await getServerSession(authOptions);
  if (session?.user.role !== "ADMIN") {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
};packages/auth/src/role-validation.ts

Usage in Next.js App (apps/web/src/pages/api/auth/[...nextauth].ts):

import NextAuth from "next-auth";
import { authOptions } from "@spacy/auth";

export default NextAuth(authOptions);apps/web/src/pages/api/auth/[...nextauth].ts

Protecting Pages with requireAdmin (apps/admin/src/pages/index.tsx):

import { requireAdmin } from "@spacy/auth";

export const getServerSideProps = async context => {
  return requireAdmin(context, ({ session }) => {
    return {
      props: { session },
    };
  });
};apps/admin/src/pages/index.tsx

3. packages/db (Database Schema & Client with Prisma)

This package hosts the Prisma Schema and the Prisma Client instance. It serves as the single source of truth for the database structure.

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log:
      process.env.NODE_ENV === "development"
        ? ["query", "error", "warn"]
        : ["error"],
  });packages/db/index.ts

Usage in API Router (packages/api/src/router/space.ts):

export const spaceRouter = createTRPCRouter({
  all: adminProcedure.query(async ({ ctx }) => {
    const spaces = await ctx.prisma.space.findMany({
      orderBy: { createdAt: "desc" },
      include: {
        user: true,
      },
    });
    return spaces;
  }),
});packages/api/src/router/space.ts

4. packages/config/* (Shared Config: ESLint, Tailwind, TSConfig)

This directory contains shared configuration files for various tools, ensuring consistency across the monorepo.

Usage in ESLint Configuration (apps/web/.eslintrc.cjs):

module.exports = {
  root: true,
  extends: ["@spacy/eslint-config"],
};apps/web/.eslintrc.cjs

Usage in TypeScript Configuration (apps/web/tsconfig.json):

{
  "extends": "@spacy/tsconfig/nextjs.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "~/*": ["./src/*"]
    }
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx",
    "**/*.cjs",
    "**/*.mjs",
    ".next/types/**/*.ts"
  ]
}apps/web/tsconfig.json

Usage in Tailwind Configuration (apps/web/tailwind.config.ts):

import baseConfig from "@spacy/tailwind-config";
import type { Config } from "tailwindcss";

export default {
  content: ["./src/**/*.tsx", "../../packages/ui/**/*.{js,ts,jsx,tsx,mdx}"],
  presets: [baseConfig],
} satisfies Config;apps/web/tailwind.config.ts

5. packages/ui (Shared UI Components with shadcn/ui, Radix UI and Tailwind CSS)

This package is our internal component library. It exports reusable UI components built with Radix UI and Tailwind CSS.

"use client";

import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";

import { cn } from "./lib/utils";

const DropdownMenu = DropdownMenuPrimitive.Root;

const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;

const DropdownMenuContent = React.forwardRef<
  React.ElementRef<typeof DropdownMenuPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
  <DropdownMenuPrimitive.Portal>
    <DropdownMenuPrimitive.Content
      ref={ref}
      sideOffset={sideOffset}
      className={cn(
        "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
        className
      )}
      {...props}
    />
  </DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;

export {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  // ... other exports
};packages/ui/src/dropdown-menu.tsx

Usage in Next.js Page (apps/admin/src/components/space-table/column.tsx):

import {
  Button,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@spacy/ui";
import { MoreHorizontal } from "lucide-react";

export const SpaceActions = ({ space }) => {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="ghost" className="h-8 w-8 p-0">
          <span className="sr-only">Open menu</span>
          <MoreHorizontal className="h-4 w-4" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuLabel>Actions</DropdownMenuLabel>
        <DropdownMenuItem
          onClick={() => void router.push(`/spaces/edit/${space.publicId}`)}
        >
          Edit
        </DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem>Delete</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
};apps/admin/src/components/.../column.tsx

6. packages/type (Shared Types & Validation)

This package contains Zod schemas for form validation and TypeScript type definitions shared between the frontend and backend.

export const spaceFormSchema = baseSpaceFormSchema.extend({
  imageFile: zodImageFileValidation,
});

export type UserAppSpace = BasicUserAppSpace & {
  creator: UserAppUser;
  imageUrl: string;
  isFavorited: boolean;
  isExperienced: boolean;
};packages/type/index.ts

Usage in API Router (packages/api/src/router/space.ts):

import { SpaceCategory } from "@spacy/type";
import { z } from "zod";

export const spaceRouter = createTRPCRouter({
  reccomendByUserId: protectedProcedure
    .input(
      z.object({
        category: z.nativeEnum(SpaceCategory).optional(),
      })
    )
    .query(async ({ ctx, input }) => {
      // ... implementation
    }),
});packages/api/src/router/space.ts

Optimized CI/CD Pipeline

With a monorepo, CI/CD performance is critical. We don’t want to build every app when we only change code for one app.

Turborepo Caching

We leverage Turborepo’s Remote Caching. Turbo builds a hash of the files in a package. If the hash hasn’t changed, it restores the build artifacts from the cache instead of rebuilding.

GitHub Actions Workflow

Our GitHub Actions pipeline is designed to be smart. It uses turbo to only run tasks (lint, build, test) for the packages that have changed.

name: CI

on:
  push:
    branches: ["main"]
  pull_request:
    types: [opened, synchronize]

jobs:
  build:
    name: Build and Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
        with:
          fetch-depth: 2

      - uses: pnpm/action-setup@v2
        with:
          version: 8

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install

      - name: Build and Test
        # Turbo will only run tasks for changed packages
        run: pnpm turbo build test lint
        env:
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM: ${{ secrets.TURBO_TEAM }}.github/workflows/ci.yml

Related Projects

    Mike 3.0

    Send a message to start the chat!

    You can ask the bot anything about me and it will help to find the relevant information!

    Try asking: