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
| Technology | Category | Description |
|---|---|---|
| tRPC | API Framework | End-to-end typesafe APIs without schemas or code generation. |
| TypeScript | Language | Strict type safety across the entire stack. |
| Prisma | ORM | Type-safe database client for MySQL. |
| PlanetScale | Database | Serverless MySQL platform with horizontal scalability. |
| NextAuth.js | Authentication | Secure authentication with role-based access control (RBAC). |
| Zod | Validation | Schema 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].tsapps/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:
- No dedicated backend servers to manage or patch.
- Infinite scalability as functions scale up/down with traffic.
- Zero latency between the “frontend” and “backend” code during build time, ensuring perfect type synchronization.
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.tspackages/api/src/root.ts
Procedure Examples
We have three types of procedures:
- Public Procedure: No authentication required.
- Protected Procedure: Authentication required.
- Admin Procedure: Authentication required and role-based access control.
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.tspackages/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.tspackages/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.tspackages/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:
- Internal IDs:
BigIntwith auto-increment for fast joins and efficient indexing. - Public IDs:
NanoID(random string) for secure, URL-friendly external references.
This approach gives us the best of both worlds:
- Performance: Integers are faster for database joins and indexing.
- Security: Random IDs prevent enumeration attacks (guessing other IDs).
- URL Friendliness: NanoIDs are shorter and safer for URLs than UUIDs.
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.prismapackages/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.tspackages/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.tspackages/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.tspackages/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.tspackages/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.tsxpackages/ui/src/form/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.tspackages/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.tspackages/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
}),
});



