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