| name |
|---|
Fastify Backend Scaffold |
This guide provides a complete walkthrough for building a modern Node.js / Bun.js backend package with Fastify, TypeScript, Drizzle ORM, and Better Auth from scratch.
- Project Overview
- Prerequisites
- Initial Setup
- Dependencies Installation
- Configuration Files
- Project Structure
- Core Implementation
- Database Setup
- Authentication Setup
- Testing Setup
- Development Workflow
- Best Practices
This backend package is built with:
- Node.js >=24.3.0 or Bun.js >= 1.2.19 - JavaScript runtime
- Fastify 5.4.0 - High-performance web framework
- TypeScript 5.6.3 - Type-safe JavaScript
- Vitest 3.2.4 - Modern test runner (Only for Node.js)
- Drizzle ORM - Type-safe SQL ORM
- Better Auth - Authentication and authorization
- TypeBox - Runtime type validation
- OpenAPI/Swagger - API documentation
- Environment Configuration - Type-safe environment variables
- CORS - Cross-origin resource sharing
- PGLite - In-memory PostgreSQL for development
- Node.js >=24.3.0 (specified in
.nvmrc) or Bun.js >= 1.2.19 - Detect the javascript runtime based on the user request prompt, if the runtime is not provided fallback to Node.js
- Detect the current package manager:
- For Node.js can be:
pnpm,npm,yarn - For Bun.js use
bun
- For Node.js can be:
- TypeScript knowledge
- Understanding of ES modules
Create package.json with the following structure, replace the @workspace/backend with the name of the current directory:
{
"name": "@workspace/backend",
"version": "1.0.0",
"description": "Node.js backend with Fastify",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "DEV_MODE=1 node --env-file=.env --watch src/serve.ts",
"start": "node --env-file=.env src/serve.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "biome check",
"lint:fix": "biome check --write",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"typecheck": "tsc --noEmit"
},
"engines": {
"node": ">=24.3.0"
},
"imports": {
"#*": "./src/*"
}
}For Bun.js replace:
- node engines for bun
- replace the scripts section with:
{
"dev": "DEV_MODE=1 bun --watch src/serve.ts",
"start": "bun src/serve.ts",
"test": "bun test",
"test:watch": "bun test --watch",
"test:coverage": "bun test --coverage",
"lint": "bun run --bun biome check",
"lint:fix": "bun run --bun biome check --write",
"db:generate": "bun run --bun drizzle-kit generate",
"db:migrate": "bun run --bun drizzle-kit migrate",
"typecheck": "bun run --bun tsc --noEmit"
}- For Node.js
Create
.nvmrc:
24.3.0
- For Bun.js
Create
.bun-version
1.2.19
- fastify@^5.4.0
- typescript@^5.6.3
- @types/node@^24.0.15
Only for Node.js:
- vitest@^3.2.4
- @fastify/autoload@^6.3.1
- @fastify/swagger@^9.5.1
- @fastify/type-provider-typebox@^5.2.0
- @scalar/fastify-api-reference@^1.32.9
- @fastify/cors@^11.0.1
- @fastify/one-line-logger@^2.0.2
- fastify-plugin@^5.0.1
- @sinclair/typebox@^0.34.38
- fastify-typebox-module@^0.0.2
- typebox-env@^2.0.1
- drizzle-orm@^0.44.3
- @electric-sql/pglite@^0.3.5
- drizzle-kit@^0.31.4
- better-auth@^1.3.1
{
"compilerOptions": {
"rootDir": ".",
"lib": ["ESNext"],
"target": "ESNext",
"module": "NodeNext",
"moduleDetection": "force",
"allowJs": true,
"outDir": "./dist",
"moduleResolution": "NodeNext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true
},
"include": ["src/**/*", "test/**/*"],
"exclude": ["node_modules", "dist"]
}import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/'],
},
},
});import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
});Create the following directory structure:
src/
├── app.ts # Main application factory
├── serve.ts # Server entry point
├── env.ts # Environment schema
├── plugins/ # Fastify plugins
│ ├── auth.ts # Authentication plugin
│ ├── documentation.ts
│ ├── drizzle.ts # Database plugin
│ └── schemas-loader.ts
├── routes/ # API routes
│ └── posts.ts
├── db/ # Database schema
│ └── schema.ts
└── schemas/ # TypeBox schemas
├── index.ts
└── posts.ts
test/
└── app.test.ts
drizzle/ # Database migrations (empty by default)
import { Type } from '@sinclair/typebox';
import { SplitArray } from 'typebox-env';
export default Type.Object({
// Fastify
DEV_MODE: Type.Boolean({ default: false }),
PORT: Type.Number({ default: 3000 }),
HOST: Type.String({ default: '127.0.0.1' }),
LOG_LEVEL: Type.String({ default: 'info' }),
// CORS
CORS_ORIGIN: SplitArray(Type.String(), { separator: ',', default: [] }),
// Better Auth
BETTER_AUTH_SECRET: Type.String(),
// Drizzle
DATABASE_URL: Type.String({ default: 'memory' }),
DATABASE_MIGRATE: Type.Boolean({ default: true }),
// Documentation
DOCS_ENABLED: Type.Boolean({ default: false }),
});import { join } from 'node:path';
import autoload from '@fastify/autoload';
import type { TypeBoxTypeProvider } from '@fastify/type-provider-typebox';
import type { Static } from '@sinclair/typebox';
import Fastify from 'fastify';
import { parseEnv } from 'typebox-env';
import envSchema from '#env.ts';
interface AppOptions {
env: Partial<Static<typeof envSchema>>;
}
export function createApp(options?: AppOptions) {
const env = parseEnv(envSchema, {
...process.env,
...(options?.env ?? {}),
});
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
transport: env.DEV_MODE
? {
target: '@fastify/one-line-logger',
options: {
colorize: true,
hideObject: false,
},
}
: undefined,
},
ignoreTrailingSlash: true,
}).withTypeProvider<TypeBoxTypeProvider>();
app.decorate('env', env);
app.register(import('@fastify/cors'), {
origin: app.env.DEV_MODE ? '*' : app.env.CORS_ORIGIN,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
credentials: true,
maxAge: 86400,
});
app.register(autoload, {
dir: join(import.meta.dirname, 'plugins'),
encapsulate: false,
});
app.register(autoload, {
dir: join(import.meta.dirname, 'routes'),
dirNameRoutePrefix: false,
});
return app;
}
export type App = ReturnType<typeof createApp>;
declare module 'fastify' {
interface FastifyInstance {
env: Static<typeof envSchema>;
}
}import { createApp } from '#app.ts';
const app = createApp();
await app.listen({ port: app.env.PORT, host: app.env.HOST });export * from './posts.ts';import { Type } from '@sinclair/typebox';
export const Post = Type.Object({
id: Type.Number(),
title: Type.String(),
content: Type.String(),
});import type { FastifyInstance } from 'fastify';
import typeboxModule from 'fastify-typebox-module';
import * as schemas from '#schemas/index.ts';
export default async function schemasLoader(fastify: FastifyInstance) {
await fastify.register(typeboxModule, {
schemas,
});
}
declare module 'fastify-typebox-module' {
interface FastifyTypeboxModule {
schemas: typeof schemas;
}
}import fp from 'fastify-plugin';
import type { App } from '#app.ts';
export default fp(
async function documentationPlugin(app: App) {
if (!app.env.DOCS_ENABLED) {
return;
}
const authSchema = await app.getAuthOpenAPISchema();
await app.register(import('@fastify/swagger'), {
openapi: {
openapi: '3.0.0',
info: {
title: 'API',
description: 'API for the application',
version: '0.1.0',
},
tags: authSchema.tags,
paths: authSchema.paths,
components: authSchema.components,
},
});
await app.register(import('@scalar/fastify-api-reference'), {
routePrefix: '/reference',
configuration: {
title: 'API',
},
});
},
{
name: 'documentation',
dependencies: ['auth'],
},
);import { Type } from '@sinclair/typebox';
import type { App } from '#app.ts';
export const autoPrefix = '/api/posts';
export default async function (app: App) {
const { ref } = app.typeboxModule;
app.get(
'/',
{
schema: {
response: {
200: Type.Array(ref('Post')),
},
},
},
async () => {
return [
{
id: 1,
title: 'Hello World',
content: 'This is a test post',
},
{
id: 2,
title: 'Hello World 2',
content: 'This is a test post 2',
},
];
},
);
}import { boolean, integer, pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';
/** better-auth tables */
export const users = pgTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified')
.$defaultFn(() => false)
.notNull(),
image: text('image'),
createdAt: timestamp('created_at')
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp('updated_at')
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
});
export const sessions = pgTable('sessions', {
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
});
export const accounts = pgTable('accounts', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const verifications = pgTable('verifications', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').$defaultFn(() => /* @__PURE__ */ new Date()),
updatedAt: timestamp('updated_at').$defaultFn(() => /* @__PURE__ */ new Date()),
});
/** app tables */
export const postsTable = pgTable('posts', {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
title: varchar({ length: 255 }).notNull(),
content: text().notNull(),
});import { drizzle } from 'drizzle-orm/pglite';
import { migrate } from 'drizzle-orm/pglite/migrator';
import fp from 'fastify-plugin';
import * as schema from '#db/schema.ts';
export default fp(
async (app) => {
const db = drizzle(app.env.DATABASE_URL, { schema });
if (app.env.DATABASE_MIGRATE) {
app.log.info('Migrating database');
await migrate(db, { migrationsFolder: './drizzle' });
app.log.info('Database migrated');
}
app.decorate('db', db);
},
{
name: 'drizzle',
},
);
declare module 'fastify' {
interface FastifyInstance {
db: ReturnType<typeof drizzle<typeof schema>>;
}
}import { type BetterAuthOptions, betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { memoryAdapter } from 'better-auth/adapters/memory';
import { toNodeHandler } from 'better-auth/node';
import { bearer, openAPI } from 'better-auth/plugins';
import fp from 'fastify-plugin';
import type { App } from '#app.ts';
export const autoPrefix = '/api/auth';
interface AuthOptions extends BetterAuthOptions {
trustedOrigins: string[];
database?: ReturnType<typeof drizzleAdapter>;
}
const defaultAuthOptions: AuthOptions = {
trustedOrigins: [],
emailAndPassword: {
enabled: true,
},
plugins: [bearer()],
};
async function authPlugin(app: App) {
const auth = betterAuth({
...defaultAuthOptions,
secret: app.env.BETTER_AUTH_SECRET,
trustedOrigins: app.env.CORS_ORIGIN,
database: drizzleAdapter(app.db, {
provider: 'pg',
usePlural: true,
}),
});
app.decorate('auth', auth);
app.decorate('getAuthOpenAPISchema', async () => {
const authWithDocs = betterAuth({
database: memoryAdapter({}),
plugins: [openAPI()],
});
const authSchema = await authWithDocs.api.generateOpenAPISchema();
return {
tags: [{ name: 'Auth', description: 'Authentication and authorization' }],
// we remove the default tags and add the auth tag to the paths
paths: Object.fromEntries(
Object.entries(authSchema.paths ?? {}).map(([path, pathItem]) => [
`${autoPrefix}${path}`,
{
...pathItem,
...(typeof pathItem === 'object' && pathItem !== null
? Object.fromEntries(
Object.entries(pathItem).map(([method, operation]) => [
method,
{
...operation,
tags: ['Auth'],
security: authSchema.security,
},
]),
)
: {}),
},
]),
),
components: authSchema.components,
};
});
const authHandler = toNodeHandler(auth);
app.addContentTypeParser(
'application/json',
/* c8 ignore next 3 */
(_request, _payload, done) => {
done(null, null);
},
);
app.route({
method: ['GET', 'POST'],
url: '*',
async handler(request, reply) {
await authHandler(request.raw, reply.raw);
},
});
}
export default fp(authPlugin, {
name: 'auth',
dependencies: ['@fastify/cors', 'drizzle'],
});
type Auth = ReturnType<typeof betterAuth<typeof defaultAuthOptions>>;
declare module 'fastify' {
interface FastifyInstance {
auth: Auth;
getAuthOpenAPISchema: () => Promise<{
tags: { name: string; description: string }[];
paths: Record<string, Record<string, any>>;
components: Record<string, Record<string, any>>;
}>;
}
}For Node.js:
import { describe, expect, it } from 'vitest';
import { createApp } from '#app.ts';
describe('Server', () => {
it('should create a Fastify instance', () => {
const fastify = createApp();
expect(fastify).toBeDefined();
});
});For Bun.js:
import { describe, expect, it } from 'bun:test';
import { createApp } from '#app.ts';
describe('Server', () => {
it('should create a Fastify instance', () => {
const fastify = createApp();
expect(fastify).toBeDefined();
});
});Create .env file:
DEV_MODE=true
PORT=3000
HOST=127.0.0.1
LOG_LEVEL=info
CORS_ORIGIN=http://localhost:3000,http://localhost:3001
BETTER_AUTH_SECRET=your-secret-key-here
DATABASE_URL=memory
DATABASE_MIGRATE=true
DOCS_ENABLED=trueFor Bun.js it's very important to add FASTIFY_AUTOLOAD_TYPESCRIPT=1 to the environment variables.
dev- Start development server with hot reloadstart- Start production servertest- Run tests oncetest:watch- Run tests in watch modetest:coverage- Run tests with coverage reportlint- Run lintinglint:fix- Fix linting issuesdb:generate- Generate database migrationsdb:migrate- Run database migrationstypecheck- Run type checking
- Type Safety: Full TypeScript support with TypeBox runtime validation
- Auto-loading: Routes and plugins are automatically loaded
- Database Integration: Drizzle ORM with PostgreSQL support
- Authentication: Better Auth with email/password and bearer tokens
- CORS Support: Configurable cross-origin resource sharing
- API Documentation: OpenAPI/Swagger with Scalar UI
- Environment Management: Type-safe environment variables
- Testing: Vitest with coverage reporting for Node.js and
bun:testfor Bun.js - Development: Hot reload with Node.js watch mode
- Logging: Configurable logging with one-line logger in dev mode
Use the #* import alias for clean imports:
import { createApp } from '#app.ts';
import type { App } from '#app.ts';- Use TypeBox for runtime validation
- Leverage TypeScript's type system
- Define schemas for all API endpoints
- Use Drizzle ORM for type-safe database operations
- Keep plugins modular and focused
- Use autoload for automatic plugin registration
- Separate concerns (documentation, schemas, auth, database)
- Use fastify-plugin for proper encapsulation
- Use TypeBox for environment schema validation
- Provide sensible defaults
- Enable/disable features via environment variables
- Use SplitArray for comma-separated values
- Use Drizzle ORM for type-safe database operations
- Generate migrations with
drizzle-kit - Use PGLite for development database
- Implement proper database schema with relationships
- Use Better Auth for authentication
- Configure proper CORS settings
- Implement bearer token authentication
- Set up proper session management
- Test application factory functions
- Maintain good test coverage
- Test database operations with proper setup/teardown
- Use consistent route prefixes (
/api) - Implement proper HTTP status codes
- Document APIs with OpenAPI/Swagger
- Use TypeBox schemas for request/response validation
- Use one-line logger in development mode
- Configure proper CORS for frontend integration
- Enable hot reload for faster development
- Use memory database for development
- Module Resolution: Ensure
tsconfig.jsonhas correctmoduleResolution - Import Aliases: Verify
package.jsonimports configuration - Environment Variables: Check
.envfile exists and variables are correct - Type Errors: Ensure all TypeBox schemas are properly exported
- Database Connection: Verify
DATABASE_URLis correctly configured - Authentication: Ensure
BETTER_AUTH_SECRETis set and secure - CORS Issues: Check
CORS_ORIGINconfiguration for frontend integration
- Fastify is already optimized for performance
- Use appropriate logging levels
- Consider caching strategies for production
- Monitor memory usage with large payloads
- Use connection pooling for database connections
- Implement proper authentication caching
- Use strong secrets for authentication
- Configure proper CORS origins
- Implement rate limiting for API endpoints
- Use HTTPS in production
- Validate all input data with TypeBox schemas
- Implement proper session management
This scaffold provides a solid foundation for building scalable, type-safe Node.js backend applications with Fastify, complete with database integration, authentication, and modern development tooling.
After you have finished the task, you must create a .cursor/rules/backend.md file with the content defined in the following sections:
Project OverviewPrerequisitesProject StructureBest PracticesTroubleshooting
The header of the file should be:
---
description: Backend Rules
globs: **/*.ts, **/*.tsx, **/*.js, **/*.jsx, **/*.json, **/*.md, **/*.env
---