Skip to main content

Overview

Featul’s API layer uses jstack, a type-safe RPC framework built on top of Hono. jstack provides end-to-end type safety from server to client with minimal boilerplate.

Why jstack?

  • Type Safety: Full TypeScript inference from server to client
  • Performance: Built on Hono, optimized for edge runtimes
  • DX: Minimal API surface, intuitive patterns
  • Flexibility: Works with any transport layer
  • Serialization: SuperJSON support for complex types (Dates, Maps, Sets)

Architecture

Core Setup

From packages/api/src/jstack.ts:10:
import { jstack } from "jstack"
import { db } from "@featul/db"

export const j = jstack.init()

Middleware

Database Middleware

Injects database client into context:
const databaseMiddleware = j.middleware(async ({ next }) => {
  return await next({ db: db as any })
})
Every procedure gets ctx.db for database access.

Authentication Middleware

From packages/api/src/jstack.ts:16:
const authMiddleware = j.middleware(async ({ next, c }) => {
  const req: Request = (c as any)?.req?.raw || (c as any)?.request
  const session = await auth.api.getSession({ 
    headers: req?.headers || (await headers()) 
  })
  
  if (!session || !session.user) {
    throw new HTTPException(401, { message: "Unauthorized" })
  }
  
  enforceTrustedBrowserOrigin(req)
  return await next({ session })
})
Private procedures get ctx.session with user information.

Procedure Types

From packages/api/src/jstack.ts:56:
const baseProcedure = j.procedure.use(databaseMiddleware)

export const publicProcedure = baseProcedure
export const privateProcedure = baseProcedure.use(authMiddleware)
publicProcedure
  • No authentication required
  • Has database access via ctx.db
  • Used for public data (viewing boards, posts)
privateProcedure
  • Requires authentication
  • Has database access via ctx.db
  • Has session access via ctx.session
  • Used for protected actions (creating posts, voting)

Router Structure

Router Organization

Routers are organized by domain in packages/api/src/router/:
router/
├── workspace.ts      # Workspace CRUD, domains
├── board.ts          # Board management
├── post.ts           # Post creation, updates
├── comment.ts        # Comments and reactions
├── branding.ts       # Branding configuration
├── changelog.ts      # Changelog entries
├── team.ts           # Team member management
├── member.ts         # Workspace members
├── storage.ts        # File uploads
├── integration.ts    # Third-party integrations
├── reservation.ts    # Slug reservations
└── account.ts        # User account settings

Router Definition Pattern

From packages/api/src/router/workspace.ts:52:
export function createWorkspaceRouter() {
  return j.router({
    // GET endpoint - no input
    ping: publicProcedure.get(({ c }) => {
      return c.json({ message: "pong" })
    }),

    // GET endpoint - with input validation
    bySlug: publicProcedure
      .input(checkSlugInputSchema)  // Zod schema
      .get(async ({ ctx, input, c }) => {
        const [ws] = await ctx.db
          .select()
          .from(workspace)
          .where(eq(workspace.slug, input.slug))
          .limit(1)
        
        return c.superjson({ workspace: ws })
      }),

    // POST endpoint - authenticated
    create: privateProcedure
      .input(createWorkspaceInputSchema)
      .post(async ({ ctx, input, c }) => {
        const userId = ctx.session.user.id
        
        const [created] = await ctx.db
          .insert(workspace)
          .values({
            name: input.name,
            slug: input.slug,
            ownerId: userId,
          })
          .returning()
        
        return c.superjson({ workspace: created })
      }),
  })
}

Router Aggregation

From packages/api/src/index.ts:1:
const routerImports = {
  workspace: () => import("./router/workspace").then((m) => m.createWorkspaceRouter()),
  board: () => import("./router/board").then((m) => m.createBoardRouter()),
  post: () => import("./router/post").then((m) => m.createPostRouter()),
  // ... more routers
}

const api = j
  .router()
  .basePath("/api")
  .use(j.defaults.cors)
  .onError(j.defaults.errorHandler)

const appRouter = j.mergeRouters(api, {
  workspace: routerImports.workspace,
  board: routerImports.board,
  post: routerImports.post,
  // ... more routers
})

export type AppRouter = typeof appRouter
export default appRouter
Routers are lazily imported for better code splitting.

Input Validation

Zod Schemas

All inputs are validated with Zod in packages/api/src/validators/:
import { z } from "zod"

export const checkSlugInputSchema = z.object({
  slug: z.string().min(1).max(50),
})

export const createWorkspaceInputSchema = z.object({
  name: z.string().min(1).max(100),
  slug: z.string().min(1).max(50).regex(/^[a-z0-9-]+$/),
  domain: z.string().url(),
  timezone: z.string(),
})

Using Validators

import { createWorkspaceInputSchema } from "../validators/workspace"

export function createWorkspaceRouter() {
  return j.router({
    create: privateProcedure
      .input(createWorkspaceInputSchema)  // Input is validated and typed
      .post(async ({ input }) => {
        // input.name, input.slug are fully typed
      })
  })
}

Client Usage

Type-Safe Client

Client is auto-generated from router types:
import { client } from "@featul/api/client"

// GET request
const { workspace } = await client.workspace.bySlug.$get({ 
  slug: "mantlz" 
})

// POST request
const { workspace } = await client.workspace.create.$post({
  name: "Acme Inc",
  slug: "acme",
  domain: "https://acme.com",
  timezone: "America/New_York",
})
All requests are fully typed based on the server-side router.

With React Query

import { useQuery, useMutation } from "@tanstack/react-query"
import { client } from "@featul/api/client"

function useWorkspace(slug: string) {
  return useQuery({
    queryKey: ['workspace', slug],
    queryFn: async () => {
      const data = await client.workspace.bySlug.$get({ slug })
      return data.workspace
    },
  })
}

function useCreateWorkspace() {
  return useMutation({
    mutationFn: (data) => client.workspace.create.$post(data),
  })
}

Response Patterns

JSON Response

For simple data:
return c.json({ message: "Success", data: result })

SuperJSON Response

For complex types (Date, Map, Set, etc.):
return c.superjson({ 
  workspace: { 
    ...ws, 
    createdAt: new Date()  // Serialized as Date
  } 
})

Error Responses

import { HTTPException } from "hono/http-exception"

throw new HTTPException(404, { message: "Workspace not found" })
throw new HTTPException(403, { message: "Forbidden" })
throw new HTTPException(400, { message: "Invalid input" })

Cache Headers

// Public data - cache for 30s
c.header("Cache-Control", "public, max-age=30, stale-while-revalidate=300")
return c.superjson({ data })

// Private data - no cache
c.header("Cache-Control", "private, no-store")
return c.superjson({ data })

// CSV download
c.header("Content-Type", "text/csv; charset=utf-8")
c.header("Content-Disposition", `attachment; filename="export.csv"`)
return c.body(csvContent)

Common Patterns

Permission Checking

From packages/api/src/router/workspace.ts:27:
async function requireWorkspaceManager(ctx: any, slug: string) {
  const [ws] = await ctx.db
    .select({ id: workspace.id, ownerId: workspace.ownerId })
    .from(workspace)
    .where(eq(workspace.slug, slug))
    .limit(1)

  if (!ws) throw new HTTPException(404, { message: "Workspace not found" })

  const meId = ctx.session.user.id
  let allowed = ws.ownerId === meId
  
  if (!allowed) {
    const [me] = await ctx.db
      .select({ role: workspaceMember.role, permissions: workspaceMember.permissions })
      .from(workspaceMember)
      .where(and(
        eq(workspaceMember.workspaceId, ws.id),
        eq(workspaceMember.userId, meId)
      ))
      .limit(1)
    
    const perms = (me?.permissions || {}) as Record<string, boolean>
    if (me?.role === "admin" || perms?.canManageWorkspace) {
      allowed = true
    }
  }
  
  if (!allowed) throw new HTTPException(403, { message: "Forbidden" })
  
  return ws
}
Usage:
delete: privateProcedure
  .input(deleteInputSchema)
  .post(async ({ ctx, input, c }) => {
    const ws = await requireWorkspaceManager(ctx, input.slug)
    // User is authorized, proceed with deletion
  })

Pagination

list: publicProcedure
  .input(z.object({
    cursor: z.number().optional(),
    limit: z.number().min(1).max(100).default(20),
  }))
  .get(async ({ ctx, input, c }) => {
    const posts = await ctx.db
      .select()
      .from(post)
      .limit(input.limit)
      .offset(input.cursor ?? 0)
    
    return c.superjson({ 
      posts,
      nextCursor: posts.length === input.limit 
        ? (input.cursor ?? 0) + input.limit 
        : undefined
    })
  })

Aggregations

From packages/api/src/router/workspace.ts:128:
statusCounts: publicProcedure
  .input(checkSlugInputSchema)
  .get(async ({ ctx, input, c }) => {
    const rows = await ctx.db
      .select({ 
        status: post.roadmapStatus, 
        count: sql<number>`count(*)` 
      })
      .from(post)
      .innerJoin(board, eq(post.boardId, board.id))
      .where(eq(board.workspaceId, workspaceId))
      .groupBy(post.roadmapStatus)

    const counts: Record<string, number> = {}
    for (const r of rows) {
      counts[r.status] = Number(r.count)
    }
    
    return c.json({ counts })
  })

File Downloads

From packages/api/src/router/workspace.ts:616:
exportCsv: privateProcedure
  .input(checkSlugInputSchema)
  .get(async ({ ctx, input, c }) => {
    const ws = await requireWorkspaceManager(ctx, input.slug)
    
    const posts = await ctx.db
      .select({
        id: post.id,
        title: post.title,
        content: post.content,
      })
      .from(post)
      .where(eq(post.workspaceId, ws.id))
    
    const csv = generateCsv(posts)
    
    c.header("Content-Type", "text/csv; charset=utf-8")
    c.header("Content-Disposition", `attachment; filename="${input.slug}-export.csv"`)
    return c.body("\uFEFF" + csv)  // UTF-8 BOM
  })

Best Practices

Naming Conventions

  • Routers: createXyzRouter() factory functions
  • Procedures: Verb-based names (create, update, delete, list)
  • Validators: {action}{Resource}InputSchema (e.g., createWorkspaceInputSchema)

Error Handling

Always throw HTTPException with appropriate status codes:
// 400 - Bad Request
throw new HTTPException(400, { message: "Invalid input" })

// 401 - Unauthorized
throw new HTTPException(401, { message: "Unauthorized" })

// 403 - Forbidden
throw new HTTPException(403, { message: "Forbidden" })

// 404 - Not Found
throw new HTTPException(404, { message: "Resource not found" })

// 409 - Conflict
throw new HTTPException(409, { message: "Slug already taken" })

// 500 - Internal Server Error
throw new HTTPException(500, { message: "Internal error" })

Type Safety

Always export and use inferred types:
export type AppRouter = typeof appRouter
This enables client-side type inference.

Context Usage

Access context properties consistently:
privateProcedure.post(async ({ ctx, input, c }) => {
  // ctx.db - database client
  // ctx.session - user session (private procedures only)
  // input - validated input
  // c - Hono context (for response methods)
})

Avoid N+1 Queries

Use joins instead of separate queries:
// Bad
const posts = await db.select().from(post)
for (const post of posts) {
  post.author = await db.select().from(user).where(eq(user.id, post.authorId))
}

// Good
const posts = await db
  .select({
    id: post.id,
    title: post.title,
    authorName: user.name,
  })
  .from(post)
  .leftJoin(user, eq(post.authorId, user.id))

Testing

While not currently implemented, API routes can be tested:
import { testClient } from "hono/testing"
import appRouter from "@featul/api"

const client = testClient(appRouter)

test("get workspace by slug", async () => {
  const res = await client.workspace.bySlug.$get({ slug: "test" })
  expect(res.status).toBe(200)
})

Performance Optimization

Response Caching

Cache public data aggressively:
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=600")

Selective Fields

Only select needed columns:
const workspaces = await ctx.db
  .select({
    id: workspace.id,
    name: workspace.name,
    slug: workspace.slug,
    // Don't select unused fields
  })
  .from(workspace)

Batch Operations

Group database operations:
const [workspace, members, boards] = await Promise.all([
  db.select().from(workspace).where(eq(workspace.id, id)),
  db.select().from(workspaceMember).where(eq(workspaceMember.workspaceId, id)),
  db.select().from(board).where(eq(board.workspaceId, id)),
])

Build docs developers (and LLMs) love