Update: This article documents the initial build and the problems encountered along the way. Many of the gotchas described here (ctx.set/get isolation, DI registration, controller HMR) have been resolved in KickJS v1.2.5–v1.2.7. The workarounds shown are still valid but no longer necessary on the latest version. See the complete project guide for the current patterns.
TL;DR
- 🏗️ Built a full Jira-like task management API (65 endpoints, 17 modules) using KickJS — a decorator-driven TypeScript framework
- 🔐 Auth with JWT + refresh tokens + role-based guards — and the debugging rabbit hole that came with it
- 💬 Real-time WebSocket chat with rooms, typing indicators, and read receipts
- 📡 Server-Sent Events for live dashboard updates on task changes
- ⚡ Background job queues with BullMQ for emails, reminders, and audit logs
- 🤕 10 gotchas that cost me hours so they don't have to cost you hours
Why This Exists
I wanted to stress-test KickJS beyond a "Hello World." So I built Vibed — a task management backend with categories, labels, sprints, comments, file attachments, real-time chat, SSE dashboards, background jobs, and cron-scheduled reminders.
Think Jira's API, but TypeScript-native, decorator-driven, and built in a weekend (okay, a long weekend).
Let's walk through the interesting parts.
The Stack
| Concern | Tool |
|---|---|
| Framework | KickJS (Express under the hood) |
| Database | MongoDB + Mongoose |
| Auth | JWT access + refresh tokens |
| Resend SDK | |
| Real-time | WebSocket (native ws) |
| Live updates | Server-Sent Events |
| Job queue | BullMQ + Redis |
| Scheduled tasks | Cron module |
| Docs | Swagger / OpenAPI |
Getting Started
KickJS has a CLI that scaffolds everything:
kick new vibed --pm pnpm
kick add auth ws mailer queue cron swagger devtools
kick g module tasks
That last command generates a full DDD module — controller, service, repository, DTOs, the works. More on that structure in a sec.
The DDD Module Pattern
Every feature in KickJS lives in a module. Each module follows a layered architecture:
src/modules/tasks/
├── task.module.ts # Wires everything together
├── task.controller.ts # HTTP routes + decorators
├── task.service.ts # Business logic
├── task.repository.ts # Mongoose queries
├── dto/
│ ├── create-task.dto.ts # Validation schemas
│ └── update-task.dto.ts
├── schemas/
│ └── task.schema.ts # Mongoose model
└── interfaces/
└── task.interface.ts # TypeScript types
Here's what a module registration looks like:
@Module({
controllers: [TaskController],
providers: [TaskService, TaskRepository],
imports: [MongooseModule.forFeature([
{ name: 'Task', schema: TaskSchema }
])],
})
export class TaskModule {}
And a typical controller:
@Controller('/tasks')
@UseGuards(AuthGuard)
export class TaskController {
@Autowired() private taskService!: TaskService;
@Get('/')
async findAll(ctx: Context) {
const user = getUser(ctx);
const query = ctx.query as ApiQueryParamsConfig;
const tasks = await this.taskService.findByWorkspace(
user.workspaceId,
query
);
return ctx.json({ data: tasks });
}
@Post('/')
@Validate(CreateTaskDto)
async create(ctx: Context) {
const user = getUser(ctx);
const body = ctx.body as CreateTaskDto;
const task = await this.taskService.create({
...body,
createdBy: user._id,
workspaceId: user.workspaceId,
});
return ctx.json({ data: task }, 201);
}
}
Clean. Predictable. Every module follows the same shape.
Authentication — The Hard Way
This section is a debugging story. Buckle up.
Chapter 1: @public() Didn't Work
KickJS ships with an auth module that has a defaultPolicy: 'RESTRICTED' setting. Every route is locked down by default — which is great, until you need public routes.
The @Public() decorator is supposed to mark routes as open. Except... it wasn't working. Every public route still returned 401 Unauthorized.
After digging through the source, I found the issue: the resolveHandler in the auth config was throwing before the @Public() metadata could be checked. The auth middleware runs globally, and when resolveHandler fails, it short-circuits.
Chapter 2: The Bridge Middleware
Solution? I built authBridgeMiddleware — a global middleware that sits before the auth guard:
export function authBridgeMiddleware(
ctx: Context,
next: NextFunction
) {
const token = ctx.headers.authorization?.split(' ')[1];
if (!token) {
ctx.set('user', null);
return next();
}
try {
const payload = verifyAccessToken(token);
ctx.set('user', payload);
} catch {
ctx.set('user', null);
}
return next();
}
This way, the auth guard always has a user (or null) to work with, and @Public() routes can proceed even without a token.
Chapter 3: The ctx.set()/ctx.get() Fix
This used to be a major gotcha — ctx.set() in middleware was invisible to ctx.get() in the handler because each got a separate RequestContext instance with its own metadata Map.
Good news: fixed in KickJS v1.2.5. The metadata Map is now stored on req and shared across all RequestContext instances for the same request. ctx.set()/ctx.get() works exactly as you'd expect:
// In middleware:
ctx.set('user', payload); // ✅ Sets on shared metadata
// In route handler:
const user = ctx.get<AuthUser>('user'); // ✅ Works!
A clean helper keeps it centralized:
export function getUser(ctx: Context): AuthUser {
const user = ctx.get<AuthUser>('user');
if (!user) {
throw new UnauthorizedException('Not authenticated');
}
return user;
}
Real-Time Chat with WebSocket
KickJS has first-class WebSocket support via @WsController:
@WsController('/chat')
export class ChatGateway {
@Autowired() private chatService!: ChatService;
@OnConnect()
async handleConnect(socket: WsSocket) {
const user = await this.authenticateSocket(socket);
socket.data = { userId: user._id };
console.log(`🟢 ${user.name} connected`);
}
@OnMessage('join-room')
async handleJoinRoom(
socket: WsSocket,
payload: { roomId: string }
) {
socket.join(payload.roomId);
socket.to(payload.roomId).emit('user-joined', {
userId: socket.data.userId,
timestamp: new Date(),
});
}
@OnMessage('send-message')
async handleMessage(
socket: WsSocket,
payload: { roomId: string; content: string }
) {
const message = await this.chatService.create({
roomId: payload.roomId,
senderId: socket.data.userId,
content: payload.content,
});
socket.to(payload.roomId).emit('new-message', message);
}
@OnMessage('typing')
async handleTyping(
socket: WsSocket,
payload: { roomId: string; isTyping: boolean }
) {
socket.to(payload.roomId).emit('user-typing', {
userId: socket.data.userId,
isTyping: payload.isTyping,
});
}
}
Rooms, typing indicators, message persistence — all decorator-driven. The socket.join() / socket.to() API mirrors Socket.IO, which makes it easy to reason about.
Live Dashboard with SSE
For the dashboard, I didn't want WebSocket overhead. SSE is perfect for one-way server-to-client updates:
@Get('/stream')
@UseGuards(AuthGuard)
async streamUpdates(ctx: Context) {
const user = getUser(ctx);
ctx.sse((send, close) => {
const interval = setInterval(async () => {
const stats = await this.dashboardService.getStats(
user.workspaceId
);
send({ event: 'dashboard-update', data: stats });
}, 5000);
ctx.req.on('close', () => {
clearInterval(interval);
close();
});
});
}
ctx.sse() handles the headers (text/event-stream, Cache-Control, etc.) and gives you a clean send/close interface. The client just uses EventSource:
const es = new EventSource('/api/dashboard/stream', {
headers: { Authorization: `Bearer ${token}` }
});
es.addEventListener('dashboard-update', (e) => {
updateDashboard(JSON.parse(e.data));
});
Background Jobs That Actually Work
Email notifications, reminder scheduling, audit logging — all of this needs to happen off the request cycle. KickJS wraps BullMQ:
@Job('email-queue')
export class EmailJob {
@Autowired() private mailer!: MailerService;
@Process('send-welcome')
async handleWelcome(job: JobData<WelcomePayload>) {
await this.mailer.send({
to: job.data.email,
subject: 'Welcome to Vibed!',
template: 'welcome',
context: { name: job.data.name },
});
}
@Process('send-reminder')
async handleReminder(job: JobData<ReminderPayload>) {
await this.mailer.send({
to: job.data.email,
subject: `Reminder: ${job.data.taskTitle}`,
template: 'task-reminder',
context: job.data,
});
}
}
Dispatching a job from anywhere:
@Autowired() private queueService!: QueueService;
async createTask(data: CreateTaskDto) {
const task = await this.taskRepo.create(data);
// Fire-and-forget background job
await this.queueService.add('email-queue', 'send-reminder', {
email: data.assigneeEmail,
taskTitle: task.title,
dueDate: task.dueDate,
});
return task;
}
Pro tip: In development, skip Redis entirely with the ConsoleProvider:
QueueModule.register({
provider: process.env.NODE_ENV === 'development'
? 'console' // Logs jobs to terminal
: 'bullmq', // Real Redis-backed queue
connection: { host: 'localhost', port: 6379 },
})
File Uploads → Base64 → MongoDB
No S3, no Cloudinary. For a task management MVP, storing files as base64 in MongoDB is fine:
@Post('/attachments')
@FileUpload({ maxSize: 5 * 1024 * 1024 }) // 5MB
async uploadAttachment(ctx: Context) {
const file = ctx.file;
const user = getUser(ctx);
const attachment = await this.attachmentService.create({
taskId: ctx.params.taskId,
fileName: file.originalname,
mimeType: file.mimetype,
data: file.buffer.toString('base64'),
uploadedBy: user._id,
});
return ctx.json({ data: attachment }, 201);
}
Is this production-ready for large files? No. Is it perfect for an MVP with < 5MB attachments? Absolutely.
10 Gotchas That'll Save You Hours
These are the things I wish someone had told me before I started. Each one cost me at least 30 minutes of head-scratching.
1. Mongoose HMR Guard
Hot module reload re-runs your schema definitions. Mongoose hates that.
// ❌ Crashes on HMR
export const TaskModel = model('Task', TaskSchema);
// ✅ Guard it
export const TaskModel =
models.Task || model('Task', TaskSchema);
2. Double-Slash Routes
If your module prefix is /api and your controller has @Controller('/tasks'), you get /api//tasks. Both start with /.
// ❌ Results in /api//tasks
@Module({ prefix: '/api' })
@Controller('/tasks')
// ✅ Drop the leading slash on one
@Module({ prefix: '/api' })
@Controller('tasks')
3. Global vs Route Middleware Signatures
Global middleware gets (ctx, next). Route middleware gets (ctx, next) too — but the ctx object is different. Global middleware runs on the raw Express context; route middleware runs on the KickJS-wrapped context.
// Global middleware — req/res under the hood
app.use((ctx, next) => {
ctx.req.headers; // ✅ works
ctx.body; // ❌ might not be parsed yet
next();
});
// Route middleware — full KickJS context
@UseMiddleware(myMiddleware)
// ctx.body ✅, ctx.query ✅, ctx.params ✅
4. ctx.set/get Shared Across Middleware/Handler (Fixed in v1.2.5)
This was a pain point in earlier versions but is now fixed. Use ctx.set()/ctx.get() directly — no res.locals workaround needed.
5. @Inject for Constructors, @Autowired for Properties
// Constructor injection
class TaskService {
constructor(@Inject(TaskRepository) private repo: TaskRepository) {}
}
// Property injection (no constructor needed)
class TaskController {
@Autowired() private taskService!: TaskService;
}
Mix them up and you get silent undefined values. Pick one pattern per class.
6. QueueAdapter Wants String Names, Not Classes
// ❌ Nope
this.queueService.add(EmailJob, 'send-welcome', data);
// ✅ String name matching @Job('email-queue')
this.queueService.add('email-queue', 'send-welcome', data);
7. Route-less Modules Crash Express
If a module has no controllers (e.g., a pure service module), KickJS still tries to register it as a router. Empty router = Express crash.
// ✅ Add a health controller or use forRoot() pattern
@Module({
controllers: [], // This can cause issues
providers: [MyService],
})
// ✅ Better: export the service from a module that HAS routes
8. Auth defaultPolicy Blocks When resolveHandler Fails
If your resolveHandler throws, every route gets a 401 — even @Public() ones. Always wrap it:
authConfig({
defaultPolicy: 'RESTRICTED',
resolveHandler: async (ctx) => {
try {
return await verifyToken(ctx);
} catch {
return null; // Let @Public() routes through
}
},
})
9. ApiQueryParamsConfig Type Name
The type for pagination/filter/sort query params is called ApiQueryParamsConfig, not QueryParams, not ApiQuery, not PaginationOptions. Ask me how many times I searched for the wrong name.
import { ApiQueryParamsConfig } from '@kickjs/core';
@Get('/')
async findAll(ctx: Context) {
const query = ctx.query as ApiQueryParamsConfig;
// query.page, query.limit, query.sort, query.filter
}
10. loadEnv() Type Erasure
loadEnv() returns Record<string, string>. Everything is a string. Your "boolean" ENABLE_SWAGGER=true is the string "true".
// ❌ This is always truthy (it's a non-empty string)
if (env.ENABLE_SWAGGER) { ... }
// ✅ Compare strings explicitly
if (env.ENABLE_SWAGGER === 'true') { ... }
// ✅ Or parse once at startup
const config = {
enableSwagger: env.ENABLE_SWAGGER === 'true',
port: parseInt(env.PORT, 10) || 3000,
};
The Numbers
After a long weekend of building:
| Metric | Count |
|---|---|
| Modules | 17 |
| API Endpoints | 65 |
| MongoDB Collections | 13 |
| TypeScript Files | 159 |
| Type Errors | 0 |
Modules include: auth, users, workspaces, projects, sprints, tasks, task-comments, task-labels, task-categories, task-attachments, task-reminders, chat, notifications, dashboard, audit-log, file-uploads, and health.
What I'd Do Differently
-
Start with the auth bridge middleware — don't fight
@Public()for two hours first -
Use property injection everywhere —
@Autowired()is simpler than constructor@Inject() - String-based queue names from day one — saves a refactor later
-
Build the
getUser(ctx)helper immediately — usesctx.get<AuthUser>('user')under the hood, called in every guarded route
What's Next
In the next article in this series, I'll cover:
- Role-based access control — workspace admins vs members vs viewers
- Advanced query filtering — how to build a flexible filter/sort/paginate layer on top of Mongoose
- Deployment — containerizing the whole stack with Docker Compose
If you've been looking for a NestJS alternative that's lighter, faster to scaffold, and doesn't require a PhD in dependency injection — give KickJS a shot.
Got questions? Drop them in the comments — I'll answer everything. If you hit any of these gotchas yourself, I'd love to hear your war stories too.
Top comments (0)