"File Storage"

Upload, manage, and serve files with Tether's built-in storage powered by Cloudflare R2.

Overview

Tether provides file storage out of the box. Files are stored in Cloudflare R2 with automatic project isolation, served via a global CDN at cdn.tthr.io. Uploads use presigned URLs so your credentials are never exposed, and downloads are served directly from the CDN with no expiry.

Flow:

  1. Get upload URL — request a presigned upload URL from Tether.
  2. Upload to R2 — upload the file directly to Cloudflare R2.
  3. Confirm upload — notify Tether that the upload is complete.
  4. Asset stored — the asset is recorded and available via CDN.

Quick start

Access storage from a Nuxt server route using useTetherServer():

// server/api/upload.post.ts
const tether = useTetherServer();

// Upload a file
const assetId = await tether.storage.upload(file, {
  metadata: { alt: 'Profile photo' }
});

// Get a download URL
const url = await tether.storage.getUrl(assetId);

Or use the TetherClient directly from @tthr/client for client-side uploads:

import { TetherClient } from '@tthr/client';

const client = new TetherClient({
  url: 'https://tether-api.yourdomain.com',
  projectId: 'your-project-id',
});

const assetId = await client.storage.upload(file);
const url = await client.storage.getUrl(assetId);

Uploading files

Simple upload

The upload() method accepts a File or Blob and returns the asset ID:

const assetId = await tether.storage.upload(file);

With progress tracking

Track upload progress with the onProgress callback:

const assetId = await tether.storage.upload(file, {
  onProgress: ({ loaded, total }) => {
    const percent = Math.round(loaded / total * 100);
    console.log(`Uploading: ${percent}%`);
  }
});

With metadata

Attach custom metadata to your assets:

const assetId = await tether.storage.upload(file, {
  metadata: {
    alt: 'Product image',
    category: 'products',
    uploadedBy: userId
  }
});

Downloading files

Get a public CDN URL for your asset. These URLs are permanent and served from cdn.tthr.io:

const { url, contentType } = await tether.storage.getUrl(assetId);
// url → https://cdn.tthr.io/{project_id}/{asset_id}
// contentType → 'image/jpeg'

// Use in an img tag, video player, or download link
<img src={url} alt="..." />

Asset metadata

Retrieve information about a stored asset:

const metadata = await tether.storage.getMetadata(assetId);

// Returns:
// {
//   id: 'asset-id',
//   filename: 'photo.jpg',
//   contentType: 'image/jpeg',
//   size: 102400,
//   sha256: 'abc123...',
//   createdAt: '2025-01-15T10:00:00Z',
//   metadata: { alt: 'Profile photo' }
// }

Listing assets

List all assets in your project:

const { assets, totalCount } = await tether.storage.list();

// With pagination
const page2 = await tether.storage.list({
  limit: 20,
  offset: 20
});

// Filter by content type
const images = await tether.storage.list({
  contentType: 'image/'
});

Deleting assets

Remove an asset from storage:

await tether.storage.delete(assetId);

Note: Deletion is permanent. The file is removed from R2 and the metadata is deleted from your project database.

Manual upload flow

For advanced use cases (like resumable uploads or uploading from a URL), use the two-step process:

// 1. Generate a presigned upload URL
const { uploadUrl, assetId, contentType } = await tether.storage.generateUploadUrl({
  filename: 'document.pdf',
  contentType: 'application/pdf',
  metadata: { description: 'Contract' }
});

// 2. Upload directly to R2
await fetch(uploadUrl, {
  method: 'PUT',
  body: file,
  headers: { 'Content-Type': 'application/pdf' }
});

// 3. Confirm the upload
const asset = await tether.storage.confirmUpload(assetId);

Using assets in your data

Store asset IDs in your database tables to associate files with your data:

// Upload and save to user profile
const photoId = await tether.storage.upload(file);
await tether.mutation('profile.update', { photoId });

// In a query, fetch the URL
const profile = await tether.query('profile.get');
const photoUrl = await tether.storage.getUrl(profile.photoId);

Using storage in functions

Access storage directly from your query and mutation handlers via the storage context property:

import { mutation, z } from '@tthr/server';

export const uploadAvatar = mutation({
  args: z.object({
    filename: z.string(),
    contentType: z.string(),
  }),
  handler: async ({ args, storage, ctx }) => {
    // Generate an upload URL for the client
    const { uploadUrl, assetId } = await storage.generateUploadUrl(
      args.filename,
      args.contentType,
    );

    return { uploadUrl, assetId };
  },
});

export const confirmAvatar = mutation({
  args: z.object({ assetId: z.string() }),
  handler: async ({ args, storage, db, ctx }) => {
    // Confirm the upload completed
    const asset = await storage.completeUpload(args.assetId);

    // Save to the user's profile
    await db.users.update({
      where: { _id: ctx.userId },
      data: { avatarUrl: asset.url },
    });

    return asset;
  },
});

Available methods

Method Description
storage.generateUploadUrl(filename, contentType, metadata?) Get a presigned upload URL
storage.completeUpload(assetId, sha256?) Confirm an upload is complete
storage.getUrl(assetId) Get the CDN/presigned URL for an asset
storage.getMetadata(assetId) Get asset metadata (filename, size, etc.)
storage.delete(assetId) Delete an asset permanently
storage.list(options?) List assets with optional filtering

SPA access

Storage is also available from SPA clients using publishable keys. This allows client-side apps to upload and manage files directly:

import { TetherClient } from '@tthr/client';

const client = new TetherClient({
  url: 'https://tether-api.strands.gg',
  projectId: 'your-project-id',
  publishableKey: 'tthr_pk_...',
});

// Upload, list, get URLs — all work with publishable keys
const assetId = await client.storage.upload(file);
const { assets } = await client.storage.list();
const url = await client.storage.getUrl(assetId);

Limits & delivery

Setting Value
Maximum file size 100 MB
Upload URL expiry 15 minutes
CDN domain cdn.tthr.io
Download URLs Permanent (no expiry)