GuidesTypeScript
Fastify with Prisma and React
Create a Fastify API on Node.js with Prisma, PostgreSQL, Better Auth, tRPC, and a React frontend on TanStack Router using Better Fullstack.
Use this stack when the API should be a Fastify service on Node.js, with Prisma for PostgreSQL and a React single-page app that calls it through tRPC.
npm create better-fullstack@latest my-fastify-api -- \ --ecosystem typescript \ --frontend tanstack-router \ --backend fastify \ --runtime node \ --database postgres \ --orm prisma \ --auth better-auth \ --api trpc \ --css-framework tailwind \ --ui-library shadcn-uiWhat this creates
- A Fastify server in
apps/serverwith@fastify/cors, a Better Auth route at/api/auth/*, and the tRPC Fastify plugin at/trpc. - A React app in
apps/webon TanStack Router, with sign-in and sign-up forms, a user menu, a protected dashboard, and a tRPC client on TanStack Query. - Prisma 7 on PostgreSQL through the
@prisma/adapter-pgdriver adapter, with the Better Auth models in their own schema file. - Better Auth with email and password sign-in, using the Prisma adapter.
- Tailwind CSS and shadcn/ui components in the web app.
- A Turborepo workspace and a reproducible
bts.jsoncstack configuration.
Generated shape
my-fastify-api/
bts.jsonc
turbo.json
apps/
server/
.env
src/index.ts
web/
.env
src/
lib/auth-client.ts
utils/trpc.ts
routes/
index.tsx
login.tsx
dashboard.tsx
packages/
api/src/
context.ts
index.ts
routers/index.ts
auth/src/index.ts
db/
prisma.config.ts
prisma/schema/
schema.prisma
auth.prisma
src/index.ts
env/src/
server.ts
web.tsThe Fastify auth route converts each Fastify request into a Fetch Request, passes it to auth.handler, and copies the status, headers, and body back to the reply. The tRPC context converts the Fastify request headers the same way and resolves the Better Auth session from them.
Routes
| Route | Purpose |
|---|---|
GET / | Returns OK. |
/api/auth/* | Better Auth endpoints for email sign-up, sign-in, session, and sign-out. |
/trpc/healthCheck | Public query that returns "OK". The web home page shows its status. |
/trpc/privateData | Protected query. It throws UNAUTHORIZED without a session. |
Fastify listens on port 3000 with its built-in logger enabled. The web app runs on Vite's port 5173.
Environment
| File | Variable | Purpose |
|---|---|---|
apps/server/.env | DATABASE_URL | PostgreSQL connection string used by Prisma and the server. |
apps/server/.env | BETTER_AUTH_SECRET | Generated random secret, at least 32 characters. |
apps/server/.env | BETTER_AUTH_URL | Public URL of the server, http://localhost:3000 locally. |
apps/server/.env | CORS_ORIGIN | Web origin the server and Better Auth trust, http://localhost:5173 locally. |
apps/web/.env | VITE_SERVER_URL | Server URL used by the auth client and the tRPC client. |
packages/db/prisma.config.ts loads apps/server/.env, so Prisma commands and the server read the same DATABASE_URL.
Run it locally
Point DATABASE_URL at a PostgreSQL database. Installing dependencies runs prisma generate through the database package's postinstall script. Then push the schema and start both apps:
npm installnpm run db:pushnpm run devThe server's dev script runs tsx watch src/index.ts. Open http://localhost:5173/login to create an account, then visit /dashboard, which calls privateData.
Add a model and a procedure
Add product models to packages/db/prisma/schema/schema.prisma and leave auth.prisma to Better Auth:
model Project {
id String @id @default(cuid())
name String
ownerId String
createdAt DateTime @default(now())
@@index([ownerId])
}Apply it and regenerate Prisma Client, which the database package writes to packages/db/prisma/generated:
npm run db:pushnpm run db:generateThen add a procedure to appRouter in packages/api/src/routers/index.ts. protectedProcedure guarantees ctx.session:
createProject: protectedProcedure
.input(z.object({ name: z.string().min(2) }))
.mutation(({ ctx, input }) =>
prisma.project.create({ data: { name: input.name, ownerId: ctx.session.user.id } }),
),Import prisma as the default export of @my-fastify-api/db and z from zod. The web app calls it with trpc.createProject.mutationOptions().
Compatibility notes
--runtime nodematches the generated scripts:tsx watchin development andnode dist/index.mjsin production.- The CLI accepts both
--api trpcand--api orpcwith Fastify and a React frontend. This guide uses tRPC, which the generated server mounts through the official tRPC Fastify adapter. - Prisma uses the
prisma-clientgenerator with an ESM output folder, so import the client from the database package rather than from@prisma/client.
Deployment notes
Build with npm run build (tsdown writes apps/server/dist) and start the server with npm run start from apps/server. The server binds to 0.0.0.0:3000. Set DATABASE_URL, BETTER_AUTH_SECRET, BETTER_AUTH_URL, and CORS_ORIGIN in its environment, and build the web app with VITE_SERVER_URL set to the deployed API. For tracked schema changes, create migrations with npm run db:migrate and apply them with prisma migrate deploy before starting new code.
Troubleshooting
| Symptom | Check |
|---|---|
Imports from prisma/generated fail | Run npm run db:generate; the client is generated, not committed. |
| Sign-up fails with a missing table error | Run npm run db:push against the database in DATABASE_URL. |
Auth requests return AUTH_FAILURE | Read the Fastify log; the auth route logs the underlying error before replying with 500. |
| Browser requests fail CORS checks | CORS_ORIGIN must match the web origin exactly, including the port. |
Comparison notes
Choose Fastify when the team wants Fastify's plugin system and logging on Node.js. Compare Elysia with Drizzle and Better Auth for a Bun server, or Hono with tRPC and Drizzle for a smaller server that runs on several runtimes.
Next steps
- Open the Stack Builder with Fastify selected.
- Read the Drizzle versus Prisma comparison.
- Review the CLI create reference.