PlaycademyPlaycademy

Custom Routes

Build custom backend API endpoints for your project

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 will be null unless you're running in platform mode.

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.pngpath = "sprites/hero.png"
  • /api/assets/audio/music/theme.mp3path = "audio/music/theme.mp3"
  • /api/assets/levels/world-1.jsonpath = "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')

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!


What's Next?

On this page