Admin Dashboard
@taskora/board is the pre-built React SPA + Hono API that taskora ships as a separate package. @taskora/nestjs exposes it as an injectable provider via TaskoraBoardModule, with the actual HTTP mounting left to main.ts — because the board is a Hono app, not an Express or Fastify router, and Nest's platform adapters don't speak Hono natively.
Installation
The board is an optional peer dependency. Install it only when you want the dashboard:
npm install @taskora/board hono @hono/node-serveryarn add @taskora/board hono @hono/node-serverpnpm add @taskora/board hono @hono/node-serverbun add @taskora/board hono @hono/node-server@taskora/board— the board itself (Hono backend + compiled SPA)hono— peer dep of@taskora/board@hono/node-server— bridge that turnsboard.fetchinto a Node http listener Nest can mount
If you skip TaskoraBoardModule.forRoot entirely, none of these packages are loaded — the module uses a dynamic import("@taskora/board") inside an async factory, so consumers who don't use the board pay zero bundle or startup cost.
Registering the module
// src/app.module.ts
import { Module } from "@nestjs/common"
import { TaskoraBoardModule, TaskoraModule } from "@taskora/nestjs"
import { redisAdapter } from "taskora/redis"
import { Redis } from "ioredis"
@Module({
imports: [
TaskoraModule.forRoot({
adapter: redisAdapter({ client: new Redis(process.env.REDIS_URL!) }),
}),
TaskoraBoardModule.forRoot({
basePath: "/board",
readOnly: false,
auth: {
cookiePassword: process.env.BOARD_COOKIE_PASSWORD!,
authenticate: async ({ username, password }) => {
// Return a user object on success, null on failure.
if (username === "admin" && password === process.env.BOARD_PASSWORD) {
return { username: "admin", role: "admin" }
}
return null
},
},
}),
],
})
export class AppModule {}Every option except name and board passes straight through to @taskora/board's createBoard(app, options). See the Board operations guide for the full BoardOptions shape — title, logo, favicon, redact, theme, refreshInterval, cors, formatters, etc.
Mounting in main.ts
The module provides the Board instance under getBoardToken(), but it's up to main.ts to attach that instance to the HTTP server. Three lines for Express:
// src/main.ts
import "reflect-metadata"
import { NestFactory } from "@nestjs/core"
import { getRequestListener } from "@hono/node-server"
import type { Board } from "@taskora/board"
import { getBoardToken } from "@taskora/nestjs"
import { AppModule } from "./app.module"
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.enableShutdownHooks()
// Pull the Board out of the DI graph and mount it.
const board = app.get<Board>(getBoardToken())
app.use("/board", getRequestListener(board.fetch))
await app.listen(3000)
}
bootstrap()That's it. Open http://localhost:3000/board/ and you get the full SPA — overview, per-task job tables, job detail with logs/progress/timeline, workflow DAG viewer, schedules, DLQ, migrations, throughput charts, live SSE updates.
Fastify
Swap NestFactory.create for the Fastify adapter and use Fastify's raw-request fall-through:
import { NestFactory } from "@nestjs/core"
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify"
import { getRequestListener } from "@hono/node-server"
import type { Board } from "@taskora/board"
import { getBoardToken } from "@taskora/nestjs"
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
)
const board = app.get<Board>(getBoardToken())
const listener = getRequestListener(board.fetch)
// Fastify raw handler fall-through for the /board prefix.
app.getHttpAdapter().getInstance().all("/board/*", (req, reply) => {
listener(req.raw, reply.raw)
})
await app.listen(3000)
}Bun's native HTTP
If you're running Nest on Bun (via @nestjs/platform-express or a Bun-native adapter), you can skip @hono/node-server entirely and call board.fetch directly:
const board = app.get<Board>(getBoardToken())
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url)
if (url.pathname.startsWith("/board")) return board.fetch(req)
return app.getHttpAdapter().getInstance()(req)
},
})Using the Board from services
You can inject the Board into any provider via @InjectBoard() — useful if you want to read its state from a custom admin endpoint, or register listeners on its underlying Hono app:
import { Injectable } from "@nestjs/common"
import { InjectBoard } from "@taskora/nestjs"
import type { Board } from "@taskora/board"
@Injectable()
export class BoardIntegrationService {
constructor(@InjectBoard() private readonly board: Board) {}
// Add a custom Hono route alongside the board's own routes.
registerExtraRoute() {
this.board.app.get("/extra", (c) => c.json({ ok: true }))
}
}Board is imported from @taskora/board, not from @taskora/nestjs — we deliberately don't re-export the type to keep @taskora/nestjs's own type graph independent of the optional peer dep.
Multi-app / multi-board
Each TaskoraBoardModule.forRoot call is bound to a specific named app via the name option. You can mount two separate boards for two different taskora apps:
@Module({
imports: [
TaskoraModule.forRoot({ adapter: primaryAdapter }),
TaskoraModule.forRoot({ name: "background", adapter: backgroundAdapter }),
TaskoraBoardModule.forRoot({ basePath: "/board" }),
TaskoraBoardModule.forRoot({ name: "background", basePath: "/board-bg" }),
],
})
export class AppModule {}// main.ts
const primaryBoard = app.get<Board>(getBoardToken())
const bgBoard = app.get<Board>(getBoardToken("background"))
app.use("/board", getRequestListener(primaryBoard.fetch))
app.use("/board-bg", getRequestListener(bgBoard.fetch))Each board shows only jobs from its own app. They don't share state, auth, or UI — they're fully isolated.
Auth patterns
The board supports two auth modes:
Built-in cookie auth
Pass a BoardAuthConfig object to auth:
TaskoraBoardModule.forRoot({
basePath: "/board",
auth: {
cookiePassword: process.env.BOARD_COOKIE_PASSWORD!, // 32+ chars
authenticate: async ({ username, password }) => {
// Validate however you want — call out to your UserService, check
// a password hash, etc. Return a user object (shape is yours to
// define; ends up in the signed cookie) or null.
return await this.validateAdmin(username, password)
},
// Optional:
loginPath: "/login", // where unauthenticated GETs redirect to
logoutPath: "/logout", // GET clears the cookie
sessionTTL: 60 * 60, // seconds; default 2h
},
})The board renders its own login page at /board/login and sets an encrypted cookie on success. You don't write any HTML.
cookiePassword must be ≥ 32 characters — taskora throws at createBoard time if it's shorter. Use openssl rand -hex 32 or a secrets manager.
Legacy callback auth
If you have an existing auth story and just want a guard fn:
TaskoraBoardModule.forRoot({
basePath: "/board",
auth: async (req) => {
// Return truthy → allow, falsy → 401
return req.headers["x-admin-token"] === process.env.ADMIN_TOKEN
},
})This path skips the login page entirely — the board assumes auth was handled upstream (e.g. by a reverse proxy or a Nest guard on a parent route). Useful when your API gateway already enforces admin auth.
readOnly: true
Regardless of auth mode, set readOnly: true in staging environments to block every mutating action (retry, cancel, trim, DLQ retry-all) while still allowing inspection:
TaskoraBoardModule.forRoot({
basePath: "/board",
readOnly: process.env.NODE_ENV !== "production",
auth: { /* ... */ },
})Environment checklist
Before production:
BOARD_COOKIE_PASSWORDset to 32+ chars (usecrypto.randomBytes(32).toString("hex")to generate).authconfigured — never ship a board without auth on a public endpoint.readOnly: truein non-prod if you want inspectors to browse without bricking anything.- Reverse proxy (nginx/cloudflare) with HTTPS enforcement — board sets
Securecookies only over HTTPS. redact: ['password', 'token', 'secret', 'apiKey'](or your own keys) so PII in job payloads doesn't leak into the UI.
See the full Board operations guide for production hardening — CORS, reverse proxy headers, session management, CSP.