Skip to content

Instantly share code, notes, and snippets.

@tinchoz49
Last active July 21, 2025 19:52
Show Gist options
  • Select an option

  • Save tinchoz49/1542e745a59b387451dac4c56771bda5 to your computer and use it in GitHub Desktop.

Select an option

Save tinchoz49/1542e745a59b387451dac4c56771bda5 to your computer and use it in GitHub Desktop.
Scaffold Fastify Backend
name
Fastify Backend Scaffold

Fastify Backend Package Scaffold Guide

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.

Table of Contents

  1. Project Overview
  2. Prerequisites
  3. Initial Setup
  4. Dependencies Installation
  5. Configuration Files
  6. Project Structure
  7. Core Implementation
  8. Database Setup
  9. Authentication Setup
  10. Testing Setup
  11. Development Workflow
  12. Best Practices

Project Overview

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

Prerequisites

  • 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
  • TypeScript knowledge
  • Understanding of ES modules

Initial Setup

1. Initialize Package Configuration

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"
}

2. Create Javascript runtime Version File

  • For Node.js Create .nvmrc:
24.3.0
  • For Bun.js Create .bun-version
1.2.19

3. Dependencies Installation

Core Dependencies

  • fastify@^5.4.0

Development Dependencies

  • typescript@^5.6.3
  • @types/node@^24.0.15

Only for Node.js:

  • vitest@^3.2.4

Fastify Ecosystem Dependencies

  • @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

TypeBox Dependencies

  • @sinclair/typebox@^0.34.38
  • fastify-typebox-module@^0.0.2
  • typebox-env@^2.0.1

Database Dependencies

  • drizzle-orm@^0.44.3
  • @electric-sql/pglite@^0.3.5
  • drizzle-kit@^0.31.4

Authentication Dependencies

  • better-auth@^1.3.1

Configuration Files

TypeScript Configuration (tsconfig.json)

{
  "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"]
}

Vitest Configuration (vitest.config.ts) (Only for Node.js)

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/'],
    },
  },
});

Drizzle Configuration (drizzle.config.ts)

import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'postgresql',
  schema: './src/db/schema.ts',
});

Project Structure

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)

Core Implementation

1. Environment Configuration (src/env.ts)

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 }),
});

2. Application Factory (src/app.ts)

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>;
  }
}

3. Server Entry Point (src/serve.ts)

import { createApp } from '#app.ts';

const app = createApp();

await app.listen({ port: app.env.PORT, host: app.env.HOST });

4. Schema Definitions (src/schemas/index.ts)

export * from './posts.ts';

5. Post Schema (src/schemas/posts.ts)

import { Type } from '@sinclair/typebox';

export const Post = Type.Object({
  id: Type.Number(),
  title: Type.String(),
  content: Type.String(),
});

6. Schema Loader Plugin (src/plugins/schemas-loader.ts)

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;
  }
}

7. Documentation Plugin (src/plugins/documentation.ts)

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'],
  },
);

8. Posts Route (src/routes/posts.ts)

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',
        },
      ];
    },
  );
}

Database Setup

1. Database Schema (src/db/schema.ts)

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(),
});

2. Database Plugin (src/plugins/drizzle.ts)

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>>;
  }
}

Authentication Setup

1. Authentication Plugin (src/plugins/auth.ts)

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>>;
    }>;
  }
}

Testing Setup

Test File (test/app.test.ts)

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();
  });
});

Development Workflow

Environment Setup

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=true

For Bun.js it's very important to add FASTIFY_AUTOLOAD_TYPESCRIPT=1 to the environment variables.

Available Scripts

  • dev - Start development server with hot reload
  • start - Start production server
  • test - Run tests once
  • test:watch - Run tests in watch mode
  • test:coverage - Run tests with coverage report
  • lint - Run linting
  • lint:fix - Fix linting issues
  • db:generate - Generate database migrations
  • db:migrate - Run database migrations
  • typecheck - Run type checking

Key Features

  1. Type Safety: Full TypeScript support with TypeBox runtime validation
  2. Auto-loading: Routes and plugins are automatically loaded
  3. Database Integration: Drizzle ORM with PostgreSQL support
  4. Authentication: Better Auth with email/password and bearer tokens
  5. CORS Support: Configurable cross-origin resource sharing
  6. API Documentation: OpenAPI/Swagger with Scalar UI
  7. Environment Management: Type-safe environment variables
  8. Testing: Vitest with coverage reporting for Node.js and bun:test for Bun.js
  9. Development: Hot reload with Node.js watch mode
  10. Logging: Configurable logging with one-line logger in dev mode

Best Practices

1. Import Aliases

Use the #* import alias for clean imports:

import { createApp } from '#app.ts';
import type { App } from '#app.ts';

2. Type Safety

  • Use TypeBox for runtime validation
  • Leverage TypeScript's type system
  • Define schemas for all API endpoints
  • Use Drizzle ORM for type-safe database operations

3. Plugin Architecture

  • Keep plugins modular and focused
  • Use autoload for automatic plugin registration
  • Separate concerns (documentation, schemas, auth, database)
  • Use fastify-plugin for proper encapsulation

4. Environment Configuration

  • Use TypeBox for environment schema validation
  • Provide sensible defaults
  • Enable/disable features via environment variables
  • Use SplitArray for comma-separated values

5. Database Management

  • Use Drizzle ORM for type-safe database operations
  • Generate migrations with drizzle-kit
  • Use PGLite for development database
  • Implement proper database schema with relationships

6. Authentication

  • Use Better Auth for authentication
  • Configure proper CORS settings
  • Implement bearer token authentication
  • Set up proper session management

7. Testing Strategy

  • Test application factory functions
  • Maintain good test coverage
  • Test database operations with proper setup/teardown

8. API Design

  • Use consistent route prefixes (/api)
  • Implement proper HTTP status codes
  • Document APIs with OpenAPI/Swagger
  • Use TypeBox schemas for request/response validation

9. Development Experience

  • Use one-line logger in development mode
  • Configure proper CORS for frontend integration
  • Enable hot reload for faster development
  • Use memory database for development

Troubleshooting

Common Issues

  1. Module Resolution: Ensure tsconfig.json has correct moduleResolution
  2. Import Aliases: Verify package.json imports configuration
  3. Environment Variables: Check .env file exists and variables are correct
  4. Type Errors: Ensure all TypeBox schemas are properly exported
  5. Database Connection: Verify DATABASE_URL is correctly configured
  6. Authentication: Ensure BETTER_AUTH_SECRET is set and secure
  7. CORS Issues: Check CORS_ORIGIN configuration for frontend integration

Performance Considerations

  • 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

Security Considerations

  • 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.

Cursor Rules

After you have finished the task, you must create a .cursor/rules/backend.md file with the content defined in the following sections:

  • Project Overview
  • Prerequisites
  • Project Structure
  • Best Practices
  • Troubleshooting

The header of the file should be:

---
description: Backend Rules
globs: **/*.ts, **/*.tsx, **/*.js, **/*.jsx, **/*.json, **/*.md, **/*.env
---
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment