Examples & Recipes

Real-world code examples for common patterns in Stacks.js applications.

Protocol context — These examples illustrate the Stacks.js expression of the draft Model–View–Action architecture. Syntax and package names do not transfer across implementations.

CRUD Operations

Basic Resource CRUD

// app/Models/Post.ts
import { Model } from '@stacksjs/orm'

export default class Post extends Model {
  static table = 'posts'

  static fields = {
    title: 'string',
    slug: 'string',
    content: 'text',
    published: 'boolean',
    publishedAt: 'datetime?',
    authorId: 'integer',
  }

  static relationships = {
    author: () => this.belongsTo(User),
    comments: () => this.hasMany(Comment),
    tags: () => this.belongsToMany(Tag),
  }
}
// app/Controllers/PostController.ts
import { Post } from '@/Models/Post'
import { Request, Response } from '@stacksjs/types'

export default class PostController {
  // List all posts
  async index(request: Request): Promise<Response> {
    const posts = await Post.query()
      .with(['author', 'tags'])
      .where('published', true)
      .orderBy('publishedAt', 'desc')
      .paginate(request.query.page ?? 1, 15)

    return Response.json(posts)
  }

  // Show single post
  async show(request: Request): Promise<Response> {
    const post = await Post.query()
      .with(['author', 'comments.author', 'tags'])
      .where('slug', request.params.slug)
      .firstOrFail()

    return Response.json(post)
  }

  // Create post
  async store(request: Request): Promise<Response> {
    const data = request.validate({
      title: 'required|string|max:200',
      content: 'required|string',
      tags: 'array',
      'tags.*': 'integer|exists:tags,id',
    })

    const post = await Post.create({
      ...data,
      slug: slugify(data.title),
      authorId: request.user.id,
    })

    if (data.tags) {
      await post.tags().attach(data.tags)
    }

    return Response.json(post, 201)
  }

  // Update post
  async update(request: Request): Promise<Response> {
    const post = await Post.findOrFail(request.params.id)

    // Authorization check
    if (post.authorId !== request.user.id) {
      return Response.json({ error: 'Unauthorized' }, 403)
    }

    const data = request.validate({
      title: 'string|max:200',
      content: 'string',
      published: 'boolean',
    })

    await post.update(data)

    return Response.json(post)
  }

  // Delete post
  async destroy(request: Request): Promise<Response> {
    const post = await Post.findOrFail(request.params.id)

    if (post.authorId !== request.user.id) {
      return Response.json({ error: 'Unauthorized' }, 403)
    }

    await post.delete()

    return Response.json({ success: true })
  }
}
// routes/api.ts
Router.resource('posts', PostController)
// Creates: GET /posts, GET /posts/:id, POST /posts, PUT /posts/:id, DELETE /posts/:id

Authentication Flows

Email/Password Registration

// app/Controllers/Auth/RegisterController.ts
export default class RegisterController {
  async store(request: Request): Promise<Response> {
    const data = request.validate({
      name: 'required|string|max:100',
      email: 'required|email|unique:users,email',
      password: 'required|string|min:8|confirmed',
    })

    const user = await User.create({
      name: data.name,
      email: data.email,
      password: await hash(data.password),
    })

    // Send verification email
    await dispatch(new SendVerificationEmail(user))

    // Log in the user
    const token = await auth.login(user)

    return Response.json({
      user,
      token,
    }, 201)
  }
}

OAuth Login (GitHub Example)

// app/Controllers/Auth/GitHubController.ts
export default class GitHubController {
  // Redirect to GitHub
  async redirect(): Promise<Response> {
    const url = oauth.github.getAuthorizationUrl({
      scopes: ['user:email'],
      state: generateState(),
    })

    return Response.redirect(url)
  }

  // Handle callback
  async callback(request: Request): Promise<Response> {
    const { code, state } = request.query

    if (!verifyState(state)) {
      return Response.redirect('/login?error=invalid_state')
    }

    const tokens = await oauth.github.getAccessToken(code)
    const githubUser = await oauth.github.getUser(tokens.accessToken)

    // Find or create user
    let user = await User.where('githubId', githubUser.id).first()

    if (!user) {
      user = await User.create({
        name: githubUser.name,
        email: githubUser.email,
        githubId: githubUser.id,
        avatar: githubUser.avatar_url,
      })
    }

    const token = await auth.login(user)

    return Response.redirect(`/dashboard?token=${token}`)
  }
}

Two-Factor Authentication

// app/Controllers/Auth/TwoFactorController.ts
export default class TwoFactorController {
  // Enable 2FA
  async enable(request: Request): Promise<Response> {
    const user = request.user
    const secret = auth.twoFactor.generateSecret()

    await user.update({
      twoFactorSecret: encrypt(secret),
      twoFactorEnabled: false, // Not enabled until confirmed
    })

    const qrCode = await auth.twoFactor.generateQRCode(user.email, secret)

    return Response.json({
      secret,
      qrCode, // Base64 image
    })
  }

  // Confirm 2FA setup
  async confirm(request: Request): Promise<Response> {
    const { code } = request.validate({
      code: 'required|string|size:6',
    })

    const user = request.user
    const secret = decrypt(user.twoFactorSecret)

    if (!auth.twoFactor.verify(code, secret)) {
      return Response.json({ error: 'Invalid code' }, 400)
    }

    // Generate recovery codes
    const recoveryCodes = auth.twoFactor.generateRecoveryCodes()

    await user.update({
      twoFactorEnabled: true,
      twoFactorRecoveryCodes: encrypt(JSON.stringify(recoveryCodes)),
    })

    return Response.json({ recoveryCodes })
  }

  // Verify 2FA during login
  async verify(request: Request): Promise<Response> {
    const { code, userId } = request.validate({
      code: 'required|string',
      userId: 'required|integer',
    })

    const user = await User.findOrFail(userId)
    const secret = decrypt(user.twoFactorSecret)

    // Check TOTP code
    if (auth.twoFactor.verify(code, secret)) {
      const token = await auth.login(user)
      return Response.json({ token })
    }

    // Check recovery code
    const recoveryCodes = JSON.parse(decrypt(user.twoFactorRecoveryCodes))
    const codeIndex = recoveryCodes.indexOf(code)

    if (codeIndex !== -1) {
      // Remove used recovery code
      recoveryCodes.splice(codeIndex, 1)
      await user.update({
        twoFactorRecoveryCodes: encrypt(JSON.stringify(recoveryCodes)),
      })

      const token = await auth.login(user)
      return Response.json({ token })
    }

    return Response.json({ error: 'Invalid code' }, 400)
  }
}

File Uploads

Image Upload with Processing

// app/Controllers/ImageController.ts
export default class ImageController {
  async store(request: Request): Promise<Response> {
    const file = request.file('image')

    // Validate
    if (!file) {
      return Response.json({ error: 'No image provided' }, 400)
    }

    if (!file.isImage()) {
      return Response.json({ error: 'File must be an image' }, 400)
    }

    if (file.size > 10 * 1024 * 1024) {
      return Response.json({ error: 'Image too large (max 10MB)' }, 400)
    }

    // Process and store variants
    const variants = await imgx(file.buffer)
      .resize(1920, 1080, { fit: 'inside' })
      .toFormat('webp', { quality: 85 })
      .variants([
        { width: 1920, suffix: '-large' },
        { width: 800, suffix: '-medium' },
        { width: 400, suffix: '-thumb' },
      ])
      .upload('images')

    // Save to database
    const image = await Image.create({
      userId: request.user.id,
      originalName: file.name,
      path: variants.original,
      variants: variants.all,
    })

    return Response.json(image, 201)
  }
}

Direct S3 Upload with Signed URLs

// app/Controllers/UploadController.ts
export default class UploadController {
  // Generate presigned URL for direct upload
  async createPresignedUrl(request: Request): Promise<Response> {
    const { filename, contentType } = request.validate({
      filename: 'required|string',
      contentType: 'required|string',
    })

    const key = `uploads/${request.user.id}/${Date.now()}-${filename}`

    const presignedUrl = await Storage.disk('s3').getSignedUrl('putObject', {
      key,
      contentType,
      expiresIn: 3600, // 1 hour
    })

    return Response.json({
      uploadUrl: presignedUrl,
      key,
      publicUrl: `https://cdn.example.com/${key}`,
    })
  }

  // Confirm upload completion
  async confirmUpload(request: Request): Promise<Response> {
    const { key } = request.validate({
      key: 'required|string',
    })

    // Verify file exists
    if (!await Storage.disk('s3').exists(key)) {
      return Response.json({ error: 'File not found' }, 404)
    }

    // Save to database
    const upload = await Upload.create({
      userId: request.user.id,
      key,
      url: `https://cdn.example.com/${key}`,
    })

    return Response.json(upload)
  }
}

Payment Integration

Stripe Checkout

// app/Controllers/CheckoutController.ts
import Stripe from 'stripe'

const stripe = new Stripe(config.services.stripe.secret)

export default class CheckoutController {
  async createSession(request: Request): Promise<Response> {
    const { items } = request.validate({
      items: 'required|array',
      'items.*.productId': 'required|exists:products,id',
      'items.*.quantity': 'required|integer|min:1',
    })

    const products = await Product.whereIn('id', items.map(i => i.productId)).get()

    const lineItems = items.map(item => {
      const product = products.find(p => p.id === item.productId)
      return {
        price_data: {
          currency: 'usd',
          product_data: {
            name: product.name,
            images: [product.imageUrl],
          },
          unit_amount: Math.round(product.price * 100),
        },
        quantity: item.quantity,
      }
    })

    const session = await stripe.checkout.sessions.create({
      mode: 'payment',
      line_items: lineItems,
      success_url: `${config.app.url}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${config.app.url}/checkout/cancel`,
      customer_email: request.user.email,
      metadata: {
        userId: request.user.id,
      },
    })

    return Response.json({ sessionId: session.id, url: session.url })
  }

  async handleWebhook(request: Request): Promise<Response> {
    const sig = request.headers.get('stripe-signature')
    const event = stripe.webhooks.constructEvent(
      request.rawBody,
      sig,
      config.services.stripe.webhookSecret
    )

    switch (event.type) {
      case 'checkout.session.completed':
        await this.handleCheckoutComplete(event.data.object)
        break
      case 'payment_intent.payment_failed':
        await this.handlePaymentFailed(event.data.object)
        break
    }

    return Response.json({ received: true })
  }

  private async handleCheckoutComplete(session: Stripe.Checkout.Session) {
    const order = await Order.create({
      userId: session.metadata.userId,
      stripeSessionId: session.id,
      status: 'paid',
      total: session.amount_total / 100,
    })

    await dispatch(new SendOrderConfirmation(order))
  }
}

Subscription Management

// app/Controllers/SubscriptionController.ts
export default class SubscriptionController {
  async subscribe(request: Request): Promise<Response> {
    const { priceId } = request.validate({
      priceId: 'required|string',
    })

    const user = request.user

    // Create or get Stripe customer
    let customerId = user.stripeCustomerId
    if (!customerId) {
      const customer = await stripe.customers.create({
        email: user.email,
        name: user.name,
      })
      customerId = customer.id
      await user.update({ stripeCustomerId: customerId })
    }

    // Create subscription
    const subscription = await stripe.subscriptions.create({
      customer: customerId,
      items: [{ price: priceId }],
      payment_behavior: 'default_incomplete',
      expand: ['latest_invoice.payment_intent'],
    })

    await Subscription.create({
      userId: user.id,
      stripeSubscriptionId: subscription.id,
      stripePriceId: priceId,
      status: subscription.status,
    })

    return Response.json({
      subscriptionId: subscription.id,
      clientSecret: subscription.latest_invoice.payment_intent.client_secret,
    })
  }

  async cancel(request: Request): Promise<Response> {
    const subscription = await Subscription.where('userId', request.user.id)
      .where('status', 'active')
      .firstOrFail()

    await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
      cancel_at_period_end: true,
    })

    await subscription.update({ cancelAtPeriodEnd: true })

    return Response.json({ success: true })
  }
}

Real-time Features

Chat Application

// routes/channels.ts
import { channel } from '@stacksjs/realtime'

// Private chat rooms
channel('chat.{roomId}', {
  async authorize(user, roomId) {
    const room = await ChatRoom.find(roomId)
    return room?.members.includes(user.id)
  },

  async join(user) {
    return {
      id: user.id,
      name: user.name,
      avatar: user.avatar,
    }
  },

  async message(data, user) {
    const message = await Message.create({
      roomId: data.roomId,
      userId: user.id,
      content: data.content,
    })

    return {
      message,
      user: { id: user.id, name: user.name },
    }
  },
})
// Client-side
import { realtime } from '@stacksjs/realtime/client'

const chat = realtime.join(`chat.${roomId}`)

// Get current members
chat.here((members) => {
  console.log('Current members:', members)
})

// Listen for members joining
chat.joining((user) => {
  console.log(`${user.name} joined`)
})

// Listen for members leaving
chat.leaving((user) => {
  console.log(`${user.name} left`)
})

// Listen for messages
chat.on('MessageSent', (data) => {
  messages.push(data.message)
})

// Send message (triggers server broadcast)
async function sendMessage(content: string) {
  await fetch('/api/chat/message', {
    method: 'POST',
    body: JSON.stringify({ roomId, content }),
  })
}

// Client-to-client typing indicator
function onTyping() {
  chat.whisper('typing', { isTyping: true })
}

chat.listenForWhisper('typing', ({ userId, isTyping }) => {
  updateTypingIndicator(userId, isTyping)
})

Live Notifications

// app/Events/NotificationEvent.ts
import { Event } from '@stacksjs/events'

export class NotificationEvent extends Event {
  constructor(public notification: Notification) {
    super()
  }

  broadcastOn(): string[] {
    return [`private-user.${this.notification.userId}`]
  }

  broadcastAs(): string {
    return 'NotificationReceived'
  }

  broadcastWith(): object {
    return {
      id: this.notification.id,
      type: this.notification.type,
      message: this.notification.message,
      createdAt: this.notification.createdAt,
    }
  }
}

// Send notification - automatically broadcasts
import { dispatch } from '@stacksjs/events'
await dispatch(new NotificationEvent(notification))

// Client-side
import { realtime } from '@stacksjs/realtime/client'

realtime.private(`user.${userId}`)
  .on('NotificationReceived', (notification) => {
    showToast(notification.message)
    updateBadgeCount()
  })

Background Jobs

Email Queue

// app/Jobs/SendWelcomeEmailJob.ts
export default class SendWelcomeEmailJob extends Job {
  queue = 'emails'
  tries = 3
  timeout = 30

  constructor(private user: User) {
    super()
  }

  async handle() {
    await Mail.send({
      to: this.user.email,
      template: 'welcome',
      data: {
        name: this.user.name,
        loginUrl: `${config.app.url}/login`,
      },
    })
  }

  async failed(error: Error) {
    await log.error('Failed to send welcome email', {
      userId: this.user.id,
      error: error.message,
    })
  }
}

// Dispatch
await dispatch(new SendWelcomeEmailJob(user))

Batch Processing

// app/Jobs/ProcessImportJob.ts
export default class ProcessImportJob extends Job {
  queue = 'imports'
  timeout = 600 // 10 minutes

  constructor(private importId: string) {
    super()
  }

  async handle() {
    const importRecord = await Import.findOrFail(this.importId)
    const file = await Storage.get(importRecord.filePath)
    const rows = parseCSV(file)

    let processed = 0
    const total = rows.length

    for (const row of rows) {
      await this.processRow(row)
      processed++

      // Update progress
      await this.updateProgress((processed / total) * 100)
    }

    await importRecord.update({ status: 'completed' })
  }

  private async processRow(row: Record<string, string>) {
    await User.updateOrCreate(
      { email: row.email },
      { name: row.name, department: row.department }
    )
  }
}

API Patterns

Pagination

// Standard pagination response
async index(request: Request): Promise<Response> {
  const page = parseInt(request.query.page ?? '1')
  const perPage = Math.min(parseInt(request.query.per_page ?? '15'), 100)

  const posts = await Post.query()
    .with('author')
    .orderBy('createdAt', 'desc')
    .paginate(page, perPage)

  return Response.json({
    data: posts.data,
    meta: {
      currentPage: posts.currentPage,
      lastPage: posts.lastPage,
      perPage: posts.perPage,
      total: posts.total,
    },
    links: {
      first: `/api/posts?page=1`,
      last: `/api/posts?page=${posts.lastPage}`,
      prev: posts.currentPage > 1 ? `/api/posts?page=${posts.currentPage - 1}` : null,
      next: posts.currentPage < posts.lastPage ? `/api/posts?page=${posts.currentPage + 1}` : null,
    },
  })
}

Filtering & Sorting

async index(request: Request): Promise<Response> {
  const query = Post.query()

  // Filtering
  if (request.query.status) {
    query.where('status', request.query.status)
  }

  if (request.query.author) {
    query.where('authorId', request.query.author)
  }

  if (request.query.search) {
    query.where((q) => {
      q.where('title', 'like', `%${request.query.search}%`)
        .orWhere('content', 'like', `%${request.query.search}%`)
    })
  }

  if (request.query.from) {
    query.where('createdAt', '>=', request.query.from)
  }

  if (request.query.to) {
    query.where('createdAt', '<=', request.query.to)
  }

  // Sorting
  const sortField = request.query.sort ?? 'createdAt'
  const sortOrder = request.query.order === 'asc' ? 'asc' : 'desc'
  query.orderBy(sortField, sortOrder)

  const posts = await query.paginate(request.query.page ?? 1)

  return Response.json(posts)
}

API Versioning

// routes/api.ts
Router.group({ prefix: '/v1' }, () => {
  Router.resource('users', UserControllerV1)
  Router.resource('posts', PostControllerV1)
})

Router.group({ prefix: '/v2' }, () => {
  Router.resource('users', UserControllerV2)
  Router.resource('posts', PostControllerV2)
})

Caching Patterns

Cache-Aside Pattern

async getPopularPosts(): Promise<Post[]> {
  const cacheKey = 'posts:popular'

  // Try cache first
  let posts = await cache.get(cacheKey)

  if (!posts) {
    // Cache miss - fetch from database
    posts = await Post.query()
      .where('published', true)
      .orderBy('views', 'desc')
      .limit(10)
      .get()

    // Store in cache for 1 hour
    await cache.put(cacheKey, posts, 3600)
  }

  return posts
}

Cache Invalidation

// app/Models/Post.ts
export default class Post extends Model {
  protected static booted() {
    this.saved(async (post) => {
      await cache.forget(`post:${post.id}`)
      await cache.tags(['posts']).flush()
    })

    this.deleted(async (post) => {
      await cache.forget(`post:${post.id}`)
      await cache.tags(['posts']).flush()
    })
  }
}

Request-Level Caching

// Middleware to cache entire responses
export class CacheResponseMiddleware implements Middleware {
  async handle(request: Request, next: Next): Promise<Response> {
    if (request.method !== 'GET') {
      return next(request)
    }

    const cacheKey = `response:${request.url}`
    const cached = await cache.get(cacheKey)

    if (cached) {
      return new Response(cached.body, {
        headers: { ...cached.headers, 'X-Cache': 'HIT' },
      })
    }

    const response = await next(request)

    if (response.status === 200) {
      await cache.put(cacheKey, {
        body: await response.clone().text(),
        headers: Object.fromEntries(response.headers),
      }, 300) // 5 minutes
    }

    response.headers.set('X-Cache', 'MISS')
    return response
  }
}