PlaycademyPlaycademy

Custom Routes

Build custom backend API endpoints

Overview

Create custom backend API endpoints for server-side application logic and external integrations.

Custom routes are perfect for:

  • Server-side validation and anti-cheat logic
  • External API calls (AI, payments, etc.)
  • Database queries and mutations
  • File storage with bucket integration

Built on Hono

Routes use Hono, a fast web framework for edge runtimes.

The Context type used in routes is native to Hono.


Setup

# Enable routes during project setup
playcademy init
# Select "Yes" for Custom API routes
# Add routes to existing project
playcademy api init

This creates a server/api/ directory with sample routes.

custom.ts
main.ts
playcademy.config.js

Writing Routes

Routes export functions for HTTP methods: GET, POST, PUT, PATCH, DELETE

Basic Examples

server/api/sample/custom.ts
export async function GET(c: Context) {
    return c.json({
        success: true,
        message: 'Hello from your custom API route!',
    })
}

export async function POST(c: Context) {
    const body = await c.req.json()
    return c.json({ success: true, data: body })
}

// Creates:
// GET /api/sample/custom
// POST /api/sample/custom
server/api/users.ts
export async function GET(c: Context) {
    const name = c.req.query('name')
    return c.json({ greeting: `Hello, ${name}!` })
}

// Called with: /api/users?name=Alice
server/api/score.ts
export async function POST(c: Context) {
    const { playerId, score } = await c.req.json()

    // Save score logic here

    return c.json({ success: true, score })
}
server/api/analytics.ts
export async function POST(c: Context) {
    const userAgent = c.req.header('User-Agent')
    const platform = userAgent?.includes('Mobile') ? 'mobile' : 'desktop'

    return c.json({ platform })
}

Accessing Platform Users

On the Playcademy platform, authenticated users are available via c.get('playcademyUser'):

server/api/user.ts
export async function GET(c: Context) {
    const playcademyUser = c.get('playcademyUser')

    if (!playcademyUser) {
        return c.json({ error: 'Not authenticated' }, 401)
    }

    // playcademyUser contains: sub (user ID), name, email, etc.
    return c.json({
        userId: playcademyUser.sub,
        message: `Hello, ${playcademyUser.name}!`,
    })
}

Local Development

During local development with the Vite Plugin, playcademyUser is only populated in platform mode.

In demo and standalone modes, this variable will be null.

Using Secrets

Access API keys and credentials securely via c.env.secrets.YOUR_KEY:

server/api/ai-prompt.ts
export async function POST(c: Context) {
    const apiKey = c.env.secrets.OPENAI_API_KEY

    const response = await fetch('https://api.openai.com/v1/chat/completions', {
        headers: {
            Authorization: `Bearer ${apiKey}`,
            'Content-Type': 'application/json',
        },
        method: 'POST',
        body: JSON.stringify({
            model: 'gpt-4',
            messages: [{ role: 'user', content: 'Hello!' }],
        }),
    })

    return c.json(await response.json())
}

Learn More

See the Secrets Guide for setup and management workflows.

Self-Dispatch

If a route needs to trigger another route in the same deployment, use c.env.SELF.fetch(...).

server/api/admin/process.ts
export async function POST(c: Context) {
    // Kick off follow-up work without relying on public-domain loopback
    c.executionCtx.waitUntil(
        c.env.SELF.fetch('/api/internal/process-batch', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ batchId: 'batch_123' }),
        }),
    )

    return c.json({ ok: true, status: 'queued' })
}

Use this pattern for internal route-to-route calls:

  • āœ… c.env.SELF.fetch('/api/internal/process')
  • āŒ fetch(new URL('/api/internal/process', c.req.url))

Avoid public-domain loopback

If you call your own public URL from a custom route, the request can fail with HTTP 522.

Use c.env.SELF.fetch(...) for same-deployment route calls.

Dynamic Routes

Use [param] syntax to capture URL parameters:

server/api/quests/[questId].ts
export async function GET(c: Context) {
    const questId = c.req.param('questId') // Extract from URL
    const quest = QUESTS[questId]

    return c.json(quest)
}

Accessible at: /api/quests/dragon-quest, /api/quests/forest-adventure, etc.

Nested Routes

Organize routes in subdirectories for better structure:

[playerId].ts
inventory.ts

This creates:

  • /api/players/:playerId (Player details)
  • /api/players/:playerId/inventory (Player inventory)

Catch-All Routes

Use [...param] to match all remaining path segments:

server/api/assets/[...path].ts
export async function GET(c: Context) {
    const path = c.req.param('path') // Captures full remaining path
    const object = await c.env.BUCKET.get(path)

    return new Response(object.body, {
        headers: {
            'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
            'Cache-Control': 'public, max-age=3600',
        },
    })
}

Matches ALL paths under /api/assets/:

  • /api/assets/sprites/hero.png → path = "sprites/hero.png"
  • /api/assets/audio/music/theme.mp3 → path = "audio/music/theme.mp3"
  • /api/assets/levels/world-1.json → path = "levels/world-1.json"

Response Types

Return different response types based on your needs:

// JSON with status code
return c.json({ error: 'Not found' }, 404)

// Plain text
return c.text('Hello, world!')

// HTML
return c.html('<h1>Hello</h1>')

// Redirect
return c.redirect('/new-location')

Reserved Routes

Playcademy reserves the /__playcademy/* namespace for platform-owned routes. Do not create custom routes under server/api/ for this namespace.


Calling Routes

Use the SDK's backend namespace to call your routes from the frontend:

import { PlaycademyClient } from '@playcademy/sdk'

const client = await PlaycademyClient.init()

// GET request
const data = await client.backend.get('/users?name=Alice')

// POST request
const result = await client.backend.post('/validate-answer', {
    questionId: 'q1',
    answer: 'paris',
})

SDK Required for Platform Users

playcademyUser is only populated when requests include a valid platform token.

MethodWorks?
sdk.backend.get() / sdk.backend.post()Yes
Plain fetch('/api/...')No
Postman/cURL without tokenNo

@playcademy/sdk automatically includes the platform token. Plain fetch does not.

Asset URLs

For <img>, <video>, or <audio> tags, use client.backend.url:

// Tagged template literal (recommended)
const imageUrl = client.backend.url`/assets/${sprite.key}`
<img src={imageUrl} />

// Regular function call
const videoUrl = client.backend.url('/videos/intro.mp4')
<video src={videoUrl} />

Development & Deployment

Start Dev Server

Start the local development server to test your routes:

When using the Vite Plugin, API routes are launched automatically:

Command
$ bun dev
Output
VITE v6.x.x  āžœ  Network: use --host to expose  āžœ  press h + enter to show help  āžœ  Local: http://localhost:5173/PLAYCADEMY v0.x.x  āžœ  Project:  My App  āžœ  Sandbox: http://localhost:4321/api  āžœ  Backend: http://localhost:5173/api (via 8788)

Or launch the CLI's dev server directly:

Command
$ playcademy dev
Output
āœ” Project API Started: http://localhost:8788/api/api/health          GET/api/sample/custom   GET, POST/api/sample/kv       GET, POST, DELETE✦ Press ctrl+c to stop

CLI Dev Server Limitations

Using playcademy dev directly does not start the sandbox.

This means playcademyUser and other platform features won't be available.

For full platform simulation, use the Vite Plugin.

Deploy

When ready, deploy your routes to staging or production:

Command
$ playcademy deploy
Output
# Routes automatically bundled and deployed# Result: https://your-app-staging.playcademy.gg/api

Auto-Discovery During Deployment

Routes in server/api/ are automatically discovered, bundled, and deployed. No configuration needed!


Hooks (Advanced)

Beyond defining custom routes, you can also customize the request pipeline itself.

This file is opt-in and lets you add middleware and a global error handler. You can also wrap the final export.

Available Exports

All exports are optional. Use only what you need.

server/hooks.ts
// Middleware: runs on every request, before route handlers
export const middleware: PlaycademyMiddleware[] = [
    async (c, next) => {
        c.set('requestId', crypto.randomUUID())
        await next()
    },
]

// Error handler: controls the error response shape
export const onError: PlaycademyOnError = (err, c) => {
    console.error(err)
    return c.json({ error: 'Something went wrong' }, 500)
}

// Handler wrapper: wraps the final Worker export
export const createHandler: PlaycademyCreateHandler = baseHandler => {
    return baseHandler // replace with e.g. Sentry.withSentry(...)
}

Ambient Types

The following types are generated automatically in playcademy-env.d.ts.

TypeDescription
PlaycademyMiddlewareA middleware function that can be used in server/hooks.ts
PlaycademyOnErrorAn error handler that can be used in server/hooks.ts
PlaycademyCreateHandlerA function that can be used in server/hooks.ts to wrap the final export

Execution Order

Hooks run at specific points in the request pipeline:

OrderLayerOwnerDescription
1Platform middlewareManagedCORS, environment setup, SDK init, user verification
2Auth session middlewareManagedIf authentication is enabled
3Your middlewareOpt-inFrom server/hooks.ts
4Your onErrorOpt-inFrom server/hooks.ts
5Built-in routesManagedHealth, Timeback, etc.
6Your custom routesOpt-inYour server/api/ handlers
7Terminal handlersManaged404 fallback, asset serving

createHandler Example

createHandler wraps the final export. This is useful for observability tools, feature flag providers, or anything that needs to intercept the handler at the platform level rather than the HTTP level.

For example, with Sentry's Cloudflare SDK:

server/hooks.ts
import * as Sentry from '@sentry/cloudflare'

export const createHandler: PlaycademyCreateHandler = baseHandler =>
    Sentry.withSentry(
        env => ({
            dsn: env.SENTRY_DSN,
            tracesSampleRate: 1.0,
        }),
        baseHandler,
    )

What's Next?

On this page