SPACY project
SPACY Backend
Work

SPACY Backend

Mar 2023
Table of Contents

Overview

The Spacy Backend is not a traditional standalone server. Instead, it is a collection of serverless functions and shared packages that power the entire Spacy platform.

Built within a Turborepo monorepo, the backend logic is modularized into internal packages (@spacy/api, @spacy/db, @spacy/auth) that are consumed directly by the Next.js applications. This architecture eliminates the need for a separate “backend repo” and ensures end-to-end type safety from the database to the frontend.

Tech Stack

TechnologyCategoryDescription
tRPCAPI FrameworkEnd-to-end typesafe APIs without schemas or code generation.
TypeScriptLanguageStrict type safety across the entire stack.
PrismaORMType-safe database client for MySQL.
PlanetScaleDatabaseServerless MySQL platform with horizontal scalability.
NextAuth.jsAuthenticationSecure authentication with role-based access control (RBAC).
ZodValidationSchema declaration and validation library.

Deployment Strategy

Since our backend logic is just a set of TypeScript functions imported by our Next.js apps, it is deployed alongside the frontend.

We use the Next.js API Routes (Pages Router) as an adapter to serve our tRPC router. In apps/web/src/pages/api/trpc/[trpc].ts, we export a handler created by createNextApiHandler.

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

When we deploy our apps (e.g., to Vercel), this API route is automatically compiled into a Serverless Function (AWS Lambda). This means:

Type-Safe API with tRPC

The core of our backend communication is tRPC. Unlike REST or GraphQL, tRPC allows us to write backend functions that can be directly called from the frontend as if they were local functions, with full TypeScript inference.

No API Glue

We don’t write API schemas (like Swagger) or fetch wrappers. The frontend simply imports the type definition of the backend router. If we change a backend procedure, the frontend immediately shows a type error if it’s using the API incorrectly.

Router Structure

Our API is organized into routers based on domain entities (e.g., space, user, auth).

import { authRouter } from "./router/auth";
import { spaceRouter } from "./router/space";
import { userRouter } from "./router/user";
import { createTRPCRouter } from "./trpc";

export const appRouter = createTRPCRouter({
  user: userRouter,
  space: spaceRouter,
  auth: authRouter,
});

export type AppRouter = typeof appRouter;packages/api/src/root.ts

Procedure Examples

We have three types of procedures:

Public Procedure

The auth router exposes a public query to fetch the current session.

import { createTRPCRouter, publicProcedure } from "../trpc";

export const authRouter = createTRPCRouter({
  getSession: publicProcedure.query(({ ctx }) => {
    return ctx.session;
  }),
});packages/api/src/router/auth.ts

Protected Procedure

Protected procedures require a valid session. They are used for actions that any logged-in user can perform, like favoring a space.

import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";

export const spaceActionRouter = createTRPCRouter({
  createFavorite: protectedProcedure
    .input(z.object({ spaceId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      // ctx.session.user is guaranteed to be defined
      return ctx.prisma.favorite.create({
        data: {
          userId: ctx.session.user.id,
          spaceId: input.spaceId,
        },
      });
    }),
});packages/api/src/router/space-action.ts

Admin Procedure

Admin procedures enforce role-based access control. Only users with the ADMIN role can execute these.

import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { adminProcedure, createTRPCRouter } from "../trpc";

export const spaceRouter = createTRPCRouter({
  // Admin-only mutation to create a new space
  create: adminProcedure
    .input(
      z.object({
        name: z.string(),
        latitude: z.number(),
        longitude: z.number(),
        category: z.nativeEnum(SpaceCategory),
      })
    )
    .mutation(async ({ ctx, input }) => {
      return ctx.prisma.space.create({
        data: {
          name: input.name,
          latitude: input.latitude,
          longitude: input.longitude,
          category: input.category,
          creatorId: ctx.session.user.id,
        },
      });
    }),
});packages/api/src/router/space.ts

Database & ORM

We use Prisma as our ORM, connected to a PlanetScale (MySQL) database. Prisma’s schema serves as the single source of truth for our data models.

The schema.prisma file defines our data models. We adopt a hybrid key strategy inspired by PlanetScale:

This approach gives us the best of both worlds:

You can read more about this strategy in my blog post: Rethinking Database Keys: Auto-Incrementing BigInt and Public IDs Approach.

model Space {
  id        BigInt @id @default(autoincrement())
  publicId  String @unique @db.VarChar(12)

  name      String
  latitude  Float
  longitude Float
  category  SpaceCategory

  creatorId String @map("creatorId")
  user      User   @relation(fields: [creatorId], references: [publicId], onDelete: Cascade)

  @@index([creatorId])
}packages/db/prisma/schema.prisma

Cross Cutting Concerns

Authentication & Authorization

Authentication is handled by NextAuth.js, configured in the @spacy/auth package. This allows us to share the session state across all applications in the monorepo.

We extend the default NextAuth session to include a role field (e.g., ADMIN, USER). This role is then checked in our tRPC middleware to protect sensitive procedures.

First, we extend the types to ensure TypeScript knows about our custom fields:

import { type DefaultSession } from "next-auth";
import { type UserRole } from "@acme/db";

declare module "next-auth" {
  interface Session extends DefaultSession {
    user: {
      id: string;
      role: UserRole;
    } & DefaultSession["user"];
  }

  interface User {
    publicId: string;
    role: UserRole;
  }
}packages/auth/src/auth-options.ts

Then, we configure the session callback to populate these fields from the database user:

export const authOptions: NextAuthOptions = {
  callbacks: {
    session({ session, user }) {
      if (session.user) {
        // Set `publicId` as Exposed User ID
        session.user.id = user.publicId;
        session.user.role = user.role;
      }
      return session;
    },
  },
  // ... other config
};packages/auth/src/auth-options.ts

Finally, we use this role in our tRPC middleware:

/**
 * Reusable middleware that requires users to have Admin role
 */
const enforceUserIsAdmin = t.middleware(({ ctx, next }) => {
  if (ctx.session?.user.role !== UserRole.ADMIN) {
    throw new TRPCError({ code: "FORBIDDEN" });
  }
  return next({
    ctx: {
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

export const adminProcedure = t.procedure.use(enforceUserIsAdmin);packages/api/src/trpc.ts

By centralizing authentication logic in a shared package, we ensure that security policies are consistently applied across all client applications.

Error Handling

We use tRPC’s Error Formatter to provide consistent error responses across the API. Specifically, we flatten Zod validation errors to make them easier for the frontend to consume and display in forms.

const t = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.cause instanceof ZodError ? error.cause.flatten() : null,
      },
    };
  },
});packages/api/src/trpc.ts

Backend Implementation

For business logic errors (e.g., resource not found, insufficient permissions), we throw TRPCError with a specific code. This ensures the frontend receives the correct HTTP status code.

import { TRPCError } from "@trpc/server";

// ... inside a procedure
const space = await ctx.prisma.space.findUnique({ where: { id: input.id } });

if (!space) {
  throw new TRPCError({
    code: "NOT_FOUND",
    message: "Space not found",
  });
}

Client Usage

On the client side, we abstract this error handling into reusable form components. For example, our FormComponentWrapper automatically displays the error message associated with a field.

import { ErrorMessage } from "@hookform/error-message";

const FormComponentWrapper: FC<Props> = props => {
  return (
    <div>
      <label>{props.label}</label>
      <ErrorMessage
        errors={props.errors}
        name={props.valueName}
        render={({ message }) => <p className="text-red-500">{message}</p>}
      />
      {props.children}
    </div>
  );
};packages/ui/src/.../form-component-wrapper.tsx

Logging

We use Pino for structured, high-performance logging. In a serverless environment like Vercel, structured logs are essential for debugging and monitoring.

We integrate Pino via a tRPC middleware to log every request, its duration, and any errors.

We integrate Pino via a tRPC middleware to log every request, its duration, and any errors. We configure it to use pino-pretty in development for readability and JSON in production for observability tools.

1. Configure the Logger

We configure Pino to use pino-pretty in development for readability, and standard JSON in production for Axiom to parse.

import { pino } from "pino";

const isDev = process.env.NODE_ENV === "development";

export const logger = pino({
  level: process.env.LOG_LEVEL || "info",
  transport: isDev
    ? {
        target: "pino-pretty",
        options: {
          colorize: true,
        },
      }
    : undefined,
  redact: ["req.headers.authorization"],
});packages/api/src/logger.ts

2. tRPC Middleware

We use a middleware to log every request, its duration, and user context.

import { logger } from "./logger";

const loggerMiddleware = t.middleware(async ({ path, type, next, ctx }) => {
  const start = Date.now();
  const result = await next();
  const durationMs = Date.now() - start;

  const meta = {
    path,
    type,
    durationMs,
    userId: ctx.session?.user.id,
  };

  if (result.ok) {
    logger.info(meta, "OK");
  } else {
    logger.error({ ...meta, error: result.error }, "Error");
  }

  return result;
});

export const publicProcedure = t.procedure.use(loggerMiddleware);packages/api/src/trpc.ts

3. Usage in Procedures

We can also import the logger directly into our procedures to log specific business events.

import { logger } from "../logger";

export const spaceRouter = createTRPCRouter({
  create: protectedProcedure
    .input(z.object({ name: z.string() }))
    .mutation(async ({ ctx, input }) => {
      logger.info(
        { userId: ctx.session.user.id, spaceName: input.name },
        "Creating new space"
      );

      // ... creation logic
    }),
});

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: