Bucket Storage
Scalable object storage for files and assets
Overview
Add scalable object storage to your project backend for files and assets.
Bucket Storage is perfect for:
- User-generated content (screenshots, replays, saved data)
- Static assets (images, audio, models)
- File uploads and downloads
- Large binary data with efficient retrieval
Getting Started
$ playcademy init # Select "Yes" for bucket storage$ playcademy bucket initNot a Database
Bucket storage is for files and binary data, not structured data.
For structured data with queries, use the Database integration instead.
Bucket Management
CLI Commands
All bucket commands work with both local and remote storage:
| Command | Description | Local | Remote |
|---|---|---|---|
bucket list | List all files | ✓ | ✓ |
bucket get <key> | Download file | ✓ | ✓ |
bucket put <key> <file> | Upload file | ✓ | ✓ |
bucket delete <key> | Delete file | ✓ | ✓ |
bucket bulk <dir> | Upload directory | ✓ | ✓ |
View Full Command Documentation
See Bucket CLI Reference for all options and flags.
Local Development
Start your dev server to use bucket storage locally:
$ playcademy devOnce your local dev server is running, you can upload files to your bucket using the CLI:
$ playcademy bucket put test.png ./screenshot.png # Upload file$ playcademy bucket list # See uploaded files$ playcademy bucket get test.png -o downloaded.png # Download fileRemote Operations
Add --remote to work with your deployed app's bucket:
$ playcademy bucket list --remote # Staging$ playcademy bucket list --remote --env production # Production$ playcademy bucket put config.json ./config.json --remote # Upload to staging$ playcademy bucket bulk ./assets --remote # Bulk upload to stagingUsing Bucket Storage
Access bucket storage via c.env.BUCKET in your API routes:
export async function PUT(c: Context): Promise<Response> {
const fileKey = c.req.query('key')
const body = await c.req.arrayBuffer()
if (!fileKey || !body) {
return c.json({ error: 'Missing file key or body' }, 400)
}
await c.env.BUCKET.put(fileKey, body, {
httpMetadata: {
contentType: c.req.header('Content-Type') || 'application/octet-stream',
},
})
return c.json({
success: true,
key: fileKey,
size: body.byteLength,
})
}export async function GET(c: Context): Promise<Response> {
const fileKey = c.req.query('key')
if (!fileKey) {
return c.json({ error: 'Missing file key' }, 400)
}
const object = await c.env.BUCKET.get(fileKey)
if (!object) {
return c.json({ error: 'File not found' }, 404)
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'Content-Length': object.size.toString(),
},
})
}export async function GET(c: Context): Promise<Response> {
const prefix = c.req.query('prefix') || ''
const listed = await c.env.BUCKET.list({ prefix })
const files = listed.objects.map(obj => ({
key: obj.key,
size: obj.size,
uploaded: obj.uploaded.toISOString(),
}))
return c.json({
success: true,
files,
truncated: listed.truncated,
})
}export async function DELETE(c: Context): Promise<Response> {
const fileKey = c.req.query('key')
if (!fileKey) {
return c.json({ error: 'Missing file key' }, 400)
}
await c.env.BUCKET.delete(fileKey)
return c.json({
success: true,
message: 'File deleted',
})
}Bucket API:
| Method | Description | Example |
|---|---|---|
get(key) | Retrieve a file | await c.env.BUCKET.get('file.png') |
put(key, data, opt) | Upload a file | await c.env.BUCKET.put('file.png', buffer) |
delete(key) | Delete a file | await c.env.BUCKET.delete('file.png') |
list(options?) | List files | await c.env.BUCKET.list({ prefix: 'img/' }) |
Common Use Cases
File Serving with Catchall Routes
A common pattern is to serve bucket files through a catchall route:
/**
* GET /api/files/{path}
* Serve any file from bucket by path
*/
export async function GET(c: Context) {
const path = c.req.param('path')
if (!path) {
return c.json({ error: 'Path required' }, 400)
}
const object = await c.env.BUCKET.get(path)
if (!object) {
return c.json({ error: 'File not found' }, 404)
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'Content-Length': object.size.toString(),
'Cache-Control': 'public, max-age=3600',
},
})
}
/**
* DELETE /api/files/{path}
* Delete any file from bucket by path
*/
export async function DELETE(c: Context) {
const path = c.req.param('path')
if (!path) {
return c.json({ error: 'Path required' }, 400)
}
await c.env.BUCKET.delete(path)
return c.json({ success: true, message: 'File deleted' })
}/**
* GET /api/files
* List all files in bucket
*/
export async function GET(c: Context) {
const listed = await c.env.BUCKET.list()
const files = listed.objects.map(obj => ({
key: obj.key,
name: obj.key.split('/').pop() || obj.key,
size: obj.size,
uploaded: obj.uploaded.toISOString(),
}))
return c.json({ success: true, files })
}
/**
* POST /api/files?name=filename.png
* Upload a file to bucket
*/
export async function POST(c: Context) {
const fileName = c.req.query('name')
if (!fileName) {
return c.json({ error: 'File name required' }, 400)
}
const body = await c.req.arrayBuffer()
const fileKey = `uploads/${Date.now()}-${fileName}`
await c.env.BUCKET.put(fileKey, body, {
httpMetadata: {
contentType: c.req.header('Content-Type') || 'application/octet-stream',
},
})
return c.json({
success: true,
key: fileKey,
size: body.byteLength,
})
}Frontend Usage:
import { PlaycademyClient } from '@playcademy/sdk'
const client = await PlaycademyClient.init()
function ImageComponent({ fileKey }: { fileKey: string }) {
// The `backend.url` helper builds the complete URL to your backend route.
const imageUrl = client.backend.url`/files/${fileKey}`
return <img src={imageUrl} alt="User upload" />
}import { PlaycademyClient } from '@playcademy/sdk'
const client = await PlaycademyClient.init()
async function uploadFile(file: File) {
const arrayBuffer = await file.arrayBuffer()
const response = await client.backend.post(
`/files?name=${encodeURIComponent(file.name)}`,
arrayBuffer,
{
'Content-Type': file.type || 'application/octet-stream',
},
)
return response.data // { key, name, size, uploaded }
}import { PlaycademyClient } from '@playcademy/sdk'
const client = await PlaycademyClient.init()
async function deleteFile(fileKey: string) {
await client.backend.delete(`/files/${fileKey}`)
}import { PlaycademyClient } from '@playcademy/sdk'
const client = await PlaycademyClient.init()
async function downloadFile(fileKey: string, fileName: string) {
const response = await client.backend.download(`/files/${fileKey}`)
const blob = await response.blob()
// Trigger browser download
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
window.URL.revokeObjectURL(url)
}Why This Pattern?
The catchall route ([...path].ts) lets you serve files with clean URLs like /api/files/images/logo.png
Compare this to the alternative of using a query parameter: /api/download?key=images/logo.png.
Perfect for serving user-generated content, static assets, or any files stored in your bucket.
Screenshot Upload
Allow players to upload screenshots:
export async function POST(c: Context) {
const userId = c.req.query('userId')
const body = await c.req.arrayBuffer()
const key = `screenshots/${userId}/${Date.now()}.png`
await c.env.BUCKET.put(key, body, {
httpMetadata: { contentType: 'image/png' },
customMetadata: { userId, uploadedAt: new Date().toISOString() },
})
return c.json({ success: true, key })
}Replay Storage
Save and retrieve replays:
// Save replay
export async function PUT(c: Context) {
const gameId = c.req.query('gameId')
const replayData = await c.req.json()
const key = `replays/${gameId}.json`
await c.env.BUCKET.put(key, JSON.stringify(replayData), {
httpMetadata: { contentType: 'application/json' },
})
return c.json({ success: true })
}
// Load replay
export async function GET(c: Context) {
const gameId = c.req.query('gameId')
const object = await c.env.BUCKET.get(`replays/${gameId}.json`)
if (!object) {
return c.json({ error: 'Replay not found' }, 404)
}
const data = await object.json()
return c.json(data)
}Asset Management
Store and serve assets:
export async function GET(c: Context) {
const assetPath = c.req.query('path')
const object = await c.env.BUCKET.get(`assets/${assetPath}`)
if (!object) {
return c.json({ error: 'Asset not found' }, 404)
}
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'Cache-Control': 'public, max-age=31536000', // 1 year cache
},
})
}Deployment
Buckets are automatically provisioned when you deploy:
$ playcademy deployBest Practices
Use descriptive keys:
// Good
await c.env.BUCKET.put('screenshots/user123/game456.png', data)
await c.env.BUCKET.put('replays/2024-01/game789.json', data)
// Avoid
await c.env.BUCKET.put('file1.png', data)Set content types:
await c.env.BUCKET.put(key, data, {
httpMetadata: { contentType: 'image/png' },
})Use prefixes for organization:
// List only user's screenshots
const listed = await c.env.BUCKET.list({ prefix: `screenshots/${userId}/` })