PlaycademyPlaycademy

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 init

Not 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:

CommandDescriptionLocalRemote
bucket listList 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:

Command
$ playcademy dev

Once your local dev server is running, you can upload files to your bucket using the CLI:

Command
$ playcademy bucket put test.png ./screenshot.png  # Upload file$ playcademy bucket list  # See uploaded files$ playcademy bucket get test.png -o downloaded.png  # Download file

Remote Operations

Add --remote to work with your deployed app's bucket:

Command
$ 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 staging

Using Bucket Storage

Access bucket storage via c.env.BUCKET in your API routes:

server/api/upload.ts
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,
    })
}
server/api/download.ts
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(),
        },
    })
}
server/api/list-files.ts
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,
    })
}
server/api/delete-file.ts
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:

MethodDescriptionExample
get(key)Retrieve a fileawait c.env.BUCKET.get('file.png')
put(key, data, opt)Upload a fileawait c.env.BUCKET.put('file.png', buffer)
delete(key)Delete a fileawait c.env.BUCKET.delete('file.png')
list(options?)List filesawait 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:

server/api/files/[...path].ts
/**
 * 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' })
}
server/api/files/index.ts
/**
 * 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:

src/components/ImageDisplay.tsx
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" />
}
src/lib/file-upload.ts
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 }
}
src/lib/file-manager.ts
import { PlaycademyClient } from '@playcademy/sdk'

const client = await PlaycademyClient.init()

async function deleteFile(fileKey: string) {
    await client.backend.delete(`/files/${fileKey}`)
}
src/lib/file-download.ts
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:

server/api/screenshot.ts
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:

server/api/replay.ts
// 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:

server/api/assets.ts
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:

Command
$ playcademy deploy

Best 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}/` })

What's Next?

On this page