"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:
- Get upload URL — request a presigned upload URL from Tether.
- Upload to R2 — upload the file directly to Cloudflare R2.
- Confirm upload — notify Tether that the upload is complete.
- 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) |