A production-ready NestJS 11 starter with PostgreSQL, JWT auth, the repository pattern via @hng-sdk/orm, and migrations out of the box.
- Runtime: NestJS 11 + TypeScript 5
- Database: PostgreSQL via TypeORM (accessed through
@hng-sdk/orm'sAbstractModelActionrepository pattern) - Auth: JWT access + refresh tokens (
@nestjs/jwt+ Passport) - Validation:
class-validator+class-transformerfor HTTP DTOs - Env validation:
@t3-oss/env-core+ Zod (fail-fast on missing/invalid env vars) - Docs: Swagger at
/docs - Hardening: Helmet, compression, CORS, global exception filter, response envelope
- Node.js 20+
- npm (or pnpm/yarn — adjust commands accordingly)
- A running PostgreSQL 14+ instance
# 1. Install
npm install
# 2. Configure
cp .env.example .env
# edit .env with your DB credentials and at least 32-char JWT secrets
# 3. Create the database (one-time)
createdb nestjs_starter # or your preferred client
# 4. Apply migrations
npm run migration:run
# 5. (optional) Seed an admin user
npm run seed
# creates admin@example.com / Admin@123456
# 6. Run
npm run start:devOpen http://localhost:3000/docs for the Swagger UI.
| Script | Purpose |
|---|---|
npm run start:dev |
Run with watch mode |
npm run start:debug |
Run with --inspect debugger |
npm run start:prod |
Run the compiled dist/main.js |
npm run build |
Compile to dist/ |
npm run lint |
Lint and auto-fix |
npm run format |
Prettier |
npm run test |
Unit tests |
npm run test:e2e |
End-to-end tests |
npm run test:cov |
Coverage report |
| Script | Purpose |
|---|---|
npm run migration:run |
Apply all pending migrations |
npm run migration:revert |
Revert the most recent migration |
npm run migration:show |
List migrations and their status |
npm run migration:generate src/database/migrations/<Name> |
Diff entities vs DB and generate a migration |
npm run migration:create src/database/migrations/<Name> |
Create an empty migration |
npm run schema:drop |
Drop all tables (destructive — dev only) |
npm run seed |
Run all seeders |
npm run db:reset |
Drop schema, run migrations, run seeders |
The
migration:generatescript requires a live database connection so TypeORM can diff against the current schema.
src/
├── common/ # cross-cutting: decorators, filters, interceptors
│ ├── decorators/ # @Public(), @CurrentUser()
│ ├── filters/ # global HttpExceptionFilter
│ └── interceptors/ # logging + response envelope
├── config/ # env (t3-env), app/database/jwt config
├── database/
│ ├── data-source.ts # TypeORM CLI DataSource
│ ├── migrations/
│ └── seeds/
├── modules/
│ ├── auth/ # /auth/register, /login, /refresh, /logout, /me
│ ├── health/ # /health (public)
│ ├── mail/ # nodemailer sender + queue worker for emails
│ ├── queue/ # BullMQ root config + shared queue service
│ └── users/ # CRUD example using the repository pattern
│ ├── actions/ # UserModelAction extends AbstractModelAction<User>
│ ├── dto/
│ └── entities/
├── app.module.ts
└── main.ts
Services never depend on TypeORM Repository<T> directly. Instead, each entity gets a *ModelAction class that extends AbstractModelAction<T> and exposes a uniform CRUD API (create, get, find, list, update, delete, save) plus any domain-specific helpers.
// modules/users/actions/user.action.ts
@Injectable()
export class UserModelAction extends AbstractModelAction<User> {
constructor(@InjectRepository(User) repository: Repository<User>) {
super(repository, User);
}
findByEmail(email: string) {
return this.get({ identifierOptions: { email } });
}
}// modules/users/users.service.ts
@Injectable()
export class UsersService {
constructor(private readonly userModelAction: UserModelAction) {}
findOne(id: string) {
return this.userModelAction.get({ identifierOptions: { id } });
}
}- Create
src/modules/<name>/ - Define the entity in
entities/<name>.entity.ts - Create the model action in
actions/<name>.action.ts - Implement service and controller
- Wire up the module:
imports: [TypeOrmModule.forFeature([Entity])], providers include the model action - Register the module in
AppModule.imports - Generate a migration:
npm run migration:generate src/database/migrations/Add<Name> - Apply it:
npm run migration:run
src/config/env.ts uses @t3-oss/env-core with Zod. The app fails to boot with a readable error if any required variable is missing or invalid. Import the typed env object instead of reaching into process.env:
import { env } from './config/env';
const port = env.PORT; // typed as number| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/auth/register |
POST | public | Create account, returns access + refresh tokens |
/auth/login |
POST | public | Returns access + refresh tokens |
/auth/refresh |
POST | public | Issue a new access token from a refresh token |
/auth/logout |
POST | bearer | Revoke the current refresh token |
/auth/me |
GET | bearer | Return current user |
The global JwtAuthGuard protects every route by default. Decorate handlers (or controllers) with @Public() to opt out.
This project uses BullMQ as the background job layer and Nodemailer as the mail sender:
- A service such as
AuthServicecreates a payload for the email job. - The service calls
QueueService.addJob(...)withQUEUE_NAMES.EMAILandQUEUE_JOB_NAMES.EMAIL.SEND_PASSWORD_RESET. - BullMQ stores the job in Redis using the settings from
src/modules/queue/config/bull.config.ts. MailProcessorconsumes the job and hands the payload toMailService.MailServicesends the actual email through Nodemailer.
Example enqueue call from a domain service:
await this.queueService.addJob(
QUEUE_NAMES.EMAIL,
QUEUE_JOB_NAMES.EMAIL.SEND_PASSWORD_RESET,
{
to: user.email,
resetLink: `${env.APP_URL}/reset-password?token=${rawToken}`,
},
{
jobId: `user-${user.id}`,
},
);Example processor behavior:
@Processor(QUEUE_NAMES.EMAIL)
export class MailProcessor extends WorkerHost {
async process(job: Job) {
switch (job.name) {
case QUEUE_JOB_NAMES.EMAIL.SEND_PASSWORD_RESET:
await this.mailService.sendEmail(
job.data.to,
'Password Reset Request',
resetPasswordEmailTemplate({ resetUrl: job.data.resetLink }),
);
break;
}
}
}Example mail sender behavior:
await this.transporter.sendMail({
from: env.MAIL_FROM,
to,
subject,
html,
});Required env vars for mail:
MAIL_HOSTMAIL_PORTMAIL_USERMAIL_PASSMAIL_FROMAPP_URLREDIS_URL
Recommended responsibilities:
QueueModule: registers BullMQ once and owns Redis connection defaults.MailModule: owns mail config, the Nodemailer sender, and the worker processor.AuthServiceor any domain service: enqueues the email job.MailProcessor: turns the queued job into a sent email.
TransformInterceptor wraps successful responses:
{
"success": true,
"data": { ... }
}For paginated responses, paginationMeta from @hng-sdk/orm is hoisted into meta.
Errors go through HttpExceptionFilter:
{
"success": false,
"statusCode": 400,
"error": "BadRequestException",
"message": ["email must be an email"],
"path": "/api/users",
"timestamp": "2026-04-28T12:34:56.000Z"
}See .env.example for the full list. Critical ones:
| Variable | Notes |
|---|---|
DATABASE_* |
host/port/user/password/name |
DATABASE_SYNC |
Always false in non-dev — use migrations |
DATABASE_SSL |
true for managed providers (Neon, Supabase, RDS) |
JWT_ACCESS_SECRET |
Min 32 chars |
JWT_REFRESH_SECRET |
Min 32 chars, must differ from access secret |
SWAGGER_ENABLED |
Set to false in production if you don't want public docs |
MAIL_HOST |
SMTP host used by Nodemailer |
MAIL_PORT |
SMTP port, usually 587 or 465 |
MAIL_USER |
SMTP username |
MAIL_PASS |
SMTP password |
MAIL_FROM |
Default sender address |
APP_URL |
Used to build password reset links |
REDIS_URL |
Redis connection string used by BullMQ |
UNLICENSED
Availability checking for user-chosen profile usernames. The feature enforces strict validation rules (format, length, reserved words, homoglyph protection) and is rate-limited to prevent abuse.
| Attribute | Value |
|---|---|
| Auth | None (public) |
| Rate limit | 60 req/min/IP (Redis, in-memory fallback) |
| Query param | username (string, required) |
Success 200:
{
"available": true,
"username": "normalized-username"
}Taken 409:
{
"statusCode": 409,
"error": "USERNAME_TAKEN",
"message": "Username is already taken"
}Invalid 400:
{
"statusCode": 400,
"error": "INVALID_FORMAT",
"message": "Username must be 3-30 characters, only lowercase letters, digits, and hyphens"
}Same behavior as the public endpoint but requires a Bearer JWT and bypasses the rate-limit guard. Useful for server-to-server checks.
| Rule | Detail |
|---|---|
| Min length | 3 characters |
| Max length | 30 characters |
| Allowed chars | a-z, 0-9, hyphens (-) |
| Leading/trailing hyphen | Not allowed |
| Consecutive hyphens | Not allowed (e.g. co--ol) |
| Ambiguous unicode | Cyrillic, Greek, CJK blocked |
| Reserved names | ~50 reserved keywords (admin, api, test, etc.) |
| Uniqueness | Must not exist in users table |
| Header | Value |
|---|---|
x-ratelimit-limit |
60 |
x-ratelimit-remaining |
59 (example) |
x-ratelimit-reset |
60 (seconds) |
If Redis is unreachable, falls back to an in-memory store with a 10 req/min/IP limit.
Controller (GET /usernames/check)
↓
UsernamesService (normalize → validate → check DB)
↓
UsersService → UserModelAction (DB lookup)
↓
TypeORM → PostgreSQL
| File | Responsibility |
|---|---|
src/modules/usernames/usernames.controller.ts |
Routes, public decorator, rate-limit guard |
src/modules/usernames/usernames.service.ts |
Core logic: normalization, validation, DB uniqueness check |
src/modules/usernames/dto/check-username.dto.ts |
Validates the username query parameter |
src/modules/usernames/guards/username-rate-limit.guard.ts |
Redis-backed rate limiter with in-memory fallback |
src/modules/usernames/data/reserved-keywords.ts |
Set of ~50 reserved usernames |
src/modules/usernames/username.service.spec.ts |
Unit tests |
curl -X GET 'http://localhost:3000/usernames/check?username=adebayo'{
"available": true,
"username": "adebayo"
}Username lives on the users table as varchar(30), nullable and uniquely indexed:
| Column | Type | Default |
|---|---|---|
username |
varchar(30) | null |
CREATE UNIQUE INDEX users_username_unique_idx ON users (username) WHERE username IS NOT NULL;Public profile search endpoint that allows visitors to find published user profiles by full name or username using PostgreSQL pg_trgm trigram similarity matching.
GET /search?q={query}
- Publicly accessible — no authentication required
- Rate limited to 60 requests per minute per IP
| Parameter | Type | Required | Description |
|---|---|---|---|
q |
string | Yes | Search term. Minimum 2 characters, maximum 100 characters |
{
"success": true,
"data": {
"results": [
{
"username": "adebayo",
"fullName": "Adebayo Johnson",
"bio": "Backend engineer and product builder.",
"photoUrl": "https://example.com/adebayo.jpg",
"isVerified": false
}
],
"total": 1
}
}| Field | Type | Description |
|---|---|---|
username |
string | Unique profile username |
fullName |
string | Full display name |
bio |
string or null | Profile bio, truncated to 120 chars |
photoUrl |
string or null | Profile photo URL |
isVerified |
boolean | Whether the profile is verified |
{
"success": true,
"data": {
"results": [],
"total": 0
}
}Frontend handles the empty state UI.
Returned when q is missing, under 2 characters, or blank after trimming.
{
"success": false,
"error": "Please enter at least 2 characters to search."
}- Uses PostgreSQL
pg_trgmtrigram similarity acrossfull_nameandusernamecolumns - Case-insensitive matching
- Partial matches supported — e.g. searching
adereturnsAdebayo,Adeola, etc. - Only profiles where
is_published = trueare included - Soft-deleted profiles (
deleted_at IS NOT NULL) are excluded - Results ordered by similarity score descending — most relevant first
- Exact
usernamematches are boosted above partial matches - Maximum 20 results returned per query
| Header | Value |
|---|---|
x-ratelimit-limit |
60 |
x-ratelimit-remaining |
59 (example) |
x-ratelimit-reset |
60 (seconds) |
Controller (GET /search)
↓
SearchService (input validation + orchestration)
↓
SearchAction (DB query logic — pg_trgm)
↓
TypeORM → PostgreSQL
| File | Responsibility |
|---|---|
src/modules/search/search.controller.ts |
Route, public decorator, throttle guard |
src/modules/search/search.service.ts |
Validates input, calls action, formats response |
src/modules/search/actions/search.action.ts |
All DB logic — trigram query, ordering, limit |
src/modules/search/dto/search-query.dto.ts |
Validates and transforms q |
src/modules/search/search.module.ts |
Module wiring |
src/database/migrations/1778520370661-AddProfileSearchFields.ts |
Adds columns, installs pg_trgm, creates GIN indexes |
Adds the following to the users table:
| Column | Type | Default |
|---|---|---|
username |
varchar(100) | null |
bio |
text | null |
photo_url |
varchar(500) | null |
is_published |
boolean | false |
-- Trigram indexes for similarity search
CREATE INDEX users_full_name_trgm_idx ON users USING GIN (full_name gin_trgm_ops);
CREATE INDEX users_username_trgm_idx ON users USING GIN (username gin_trgm_ops);
-- Unique index on username
CREATE UNIQUE INDEX users_username_unique_idx ON users (username) WHERE username IS NOT NULL;curl -X GET 'http://localhost:3000/search?q=ade' \
-H 'accept: */*'{
"success": true,
"data": {
"results": [
{
"username": "adebayo",
"fullName": "Adebayo Johnson",
"bio": "Backend engineer and product builder.",
"photoUrl": "https://example.com/adebayo.jpg",
"isVerified": false
}
],
"total": 1
}
}npm run build— passednpm test— passed- Live:
GET /search?q=adereturns correct shape with rate limit headers