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
Setup
# Enable routes during project setup
playcademy init
# Select "Yes" for Custom API routes# Add routes to existing project
playcademy api initThis creates a server/api/ directory with sample routes.
Writing Routes
Routes export functions for HTTP methods: GET, POST, PUT, PATCH, DELETE
Basic Examples
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/customexport async function GET(c: Context) {
const name = c.req.query('name')
return c.json({ greeting: `Hello, ${name}!` })
}
// Called with: /api/users?name=Aliceexport async function POST(c: Context) {
const { playerId, score } = await c.req.json()
// Save score logic here
return c.json({ success: true, score })
}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'):
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:
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(...).
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:
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:
This creates:
/api/players/:playerId(Player details)/api/players/:playerId/inventory(Player inventory)
Catch-All Routes
Use [...param] to match all remaining path segments:
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.
| Method | Works? |
|---|---|
sdk.backend.get() / sdk.backend.post() | Yes |
Plain fetch('/api/...') | No |
| Postman/cURL without token | No |
@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:
$ bun devVITE 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:
$ playcademy devā 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 stopCLI 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:
$ playcademy deploy# Routes automatically bundled and deployed# Result: https://your-app-staging.playcademy.gg/apiAuto-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.
// 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.
| Type | Description |
|---|---|
PlaycademyMiddleware | A middleware function that can be used in server/hooks.ts |
PlaycademyOnError | An error handler that can be used in server/hooks.ts |
PlaycademyCreateHandler | A 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:
| Order | Layer | Owner | Description |
|---|---|---|---|
| 1 | Platform middleware | Managed | CORS, environment setup, SDK init, user verification |
| 2 | Auth session middleware | Managed | If authentication is enabled |
| 3 | Your middleware | Opt-in | From server/hooks.ts |
| 4 | Your onError | Opt-in | From server/hooks.ts |
| 5 | Built-in routes | Managed | Health, Timeback, etc. |
| 6 | Your custom routes | Opt-in | Your server/api/ handlers |
| 7 | Terminal handlers | Managed | 404 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:
import * as Sentry from '@sentry/cloudflare'
export const createHandler: PlaycademyCreateHandler = baseHandler =>
Sentry.withSentry(
env => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}),
baseHandler,
)