"Vue / Nuxt"

First-class support for Vue 3 and Nuxt with reactive composables.

Installation

npm install @tthr/vue

Setup

Nuxt

Add the module to your nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['@tthr/vue/nuxt'],

  tether: {
    projectId: 'your-project-id',
    verbose: false, // Enable debug logging (optional)
  },
});

You can also configure via environment variables:

TETHER_PROJECT_ID=your-project-id
TETHER_URL=https://tether-api.strands.gg
TETHER_API_KEY=tthr_live_xxxxxxxxxxxxx
TETHER_VERBOSE=true  # Enable debug logging

Vue 3

Create a Tether instance and provide it to your app:

import { createApp } from 'vue';
import { createTether } from '@tthr/vue';
import App from './App.vue';

const tether = createTether({
  url: 'https://tether-api.yourdomain.com',
  projectId: 'your-project-id',
  authToken: () => getAuthToken(), // optional
});

const app = createApp(App);
app.use(tether);
app.mount('#app');

SPA mode (publishable key)

To call Tether functions directly from the browser without a backend proxy, use a publishable key instead of a secret API key:

const tether = createTether({
  url: 'https://tether-api.yourdomain.com',
  projectId: 'your-project-id',
  publishableKey: 'tthr_pk_xxxxxxxxxxxxx',
  authToken: () => getAuthToken(), // JWT from your auth provider
});

Publishable keys are safe for client-side code and are restricted by your domain allowlist. Function access levels control which functions are available -- see the Functions docs for details on public, authenticated, and internal access levels.

Composables

useQuery

Subscribe to a query with reactive updates. Data fetches on the server (SSR) and automatically subscribes to WebSocket updates on the client:

// With a query name string
const { data, isLoading, error, refetch } = useQuery('todos.list', { completed: false });

// Or with a generated API reference for full type safety
const { data, isLoading, error, refetch } = useQuery(api.todos.list, { completed: false });

// data is reactive and updates automatically via WebSocket
// refetch() manually re-fetches the data

You can also await useQuery to wait for the initial data to load. The returned refs still update reactively via WebSocket after that:

// Data is already populated when the promise resolves
const { data: todos } = await useQuery('todos.list', { completed: false });

useMutation

Execute mutations with loading state:

// With a mutation name string
const { mutate, isPending, error } = useMutation('todos.create');

// Or with a generated API reference
const { mutate, isPending, error } = useMutation(api.todos.create);

// In your template or handler
await mutate({ title: 'Buy milk' });

$query & $mutation

For one-shot async calls without reactive state or subscriptions. Use these in event handlers, composables, Pinia stores, or anywhere you just need the data:

// Fetch data directly — returns raw data, no refs
const messages = await $query(api.messages.listByChannel, { channelId });

// Execute a mutation directly — returns the result
const post = await $mutation(api.posts.create, { title: 'Hello' });

// Also works with string names
const todos = await $query('todos.list', { completed: false });

These are auto-imported by the Nuxt module. They proxy through the server route, so your API key stays secure.

useTetherSubscription

Subscribe to realtime updates for a specific query (client-side only in Nuxt):

const { isConnected } = useTetherSubscription(
  'todos.list',
  { completed: false },
  (data) => {
    // Called whenever the query data changes
    console.log('Todos updated:', data);
  }
);

// isConnected tracks WebSocket connection status

The subscription automatically handles reconnection with exponential backoff and resumes updates when the browser tab becomes visible again.

Database proxy

When writing functions in tether/functions/, you get access to a typed database proxy via the db parameter. This provides a Prisma-style API for all your tables.

// tether/functions/todos.ts
import { query, mutation, z } from '../_generated/db';

export const list = query({
  handler: async ({ db }) => {
    return db.todos.findMany({
      where: { completed: false },
      orderBy: { createdAt: 'desc' },
      limit: 50,
    });
  },
});

export const create = mutation({
  args: z.object({ title: z.string().min(1) }),
  handler: async ({ args, db }) => {
    return db.todos.insert({
      title: args.title,
      completed: false,
    });
  },
});

export const complete = mutation({
  args: z.object({ _id: z.string() }),
  handler: async ({ args, db }) => {
    // Update by _id (system-generated primary key)
    return db.todos.update({
      where: { _id: args._id },
      data: { completed: true },
    });
  },
});

See the Database API for all available methods.

File storage

Storage operations require the Tether client instance. In Nuxt, use useTetherServer() in server routes, or create a TetherClient directly for client-side uploads.

Server-side (Nuxt API routes)

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

export default defineEventHandler(async (event) => {
  const formData = await readMultipartFormData(event);
  const file = formData?.[0];
  if (!file) throw createError({ statusCode: 400 });

  const assetId = await tether.storage.upload(file.data, {
    metadata: { alt: 'User photo' },
  });

  // Save assetId in your data
  await tether.mutation('profile.update', { photoId: assetId });

  return { assetId };
});

Client-side (direct client)

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

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

async function handleUpload(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0];
  if (!file) return;

  const assetId = await client.storage.upload(file, {
    metadata: { alt: 'User photo' },
    onProgress: ({ loaded, total }) => {
      console.log(`${Math.round(loaded / total * 100)}%`);
    }
  });
}

// Get download URL (expires after 1 hour)
const photoUrl = await client.storage.getUrl(assetId);

// Get asset metadata
const metadata = await client.storage.getMetadata(assetId);

// Delete an asset
await client.storage.delete(assetId);

Listing assets

List all assets for the project:

const tether = useTetherServer();

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

// With pagination and filtering
const images = await tether.storage.list({
  limit: 20,
  offset: 0,
  contentType: 'image/'
});

Reactive file URLs

Combine useQuery with a server route that resolves asset URLs:

const { data: profile } = useQuery('profile.get');

const photoUrl = ref<string>('');
watch(() => profile.value?.photoId, async (photoId) => {
  if (photoId) {
    // Fetch signed URL from your own server route
    const { url } = await $fetch(`/api/storage/${photoId}`);
    photoUrl.value = url;
  }
});

Server-side usage (Nuxt)

Access Tether from your Nuxt server endpoints for webhooks, third-party integrations, and any server-side logic. Import from @tthr/vue/server.

Option 1: Global configuration (recommended)

Configure once in a Nitro plugin, then use anywhere:

// server/plugins/tether.ts
import { configureTetherServer } from '@tthr/vue/server';

export default defineNitroPlugin(() => {
  configureTetherServer({
    url: process.env.TETHER_URL!,
    projectId: process.env.TETHER_PROJECT_ID!,
    apiKey: process.env.TETHER_API_KEY,
  });
});

Then use in any API route:

// server/api/webhook.post.ts
import { useTetherServer } from '@tthr/vue/server';

export default defineEventHandler(async (event) => {
  const tether = useTetherServer();

  // Execute queries
  const users = await tether.query('users.list');

  // Execute mutations
  await tether.mutation('users.update', { id: 1, status: 'active' });

  // Execute actions (external side effects)
  await tether.action('emails.sendWelcome', { userId: 1 });

  // Access storage
  const url = await tether.storage.getUrl(assetId);

  return { ok: true };
});

Option 2: Standalone client

Create a client instance directly when you need different configuration:

// server/api/external-webhook.ts
import { createTetherServer } from '@tthr/vue/server';

const tether = createTetherServer({
  url: process.env.TETHER_URL!,
  projectId: process.env.TETHER_PROJECT_ID!,
  apiKey: process.env.TETHER_API_KEY,
});

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
  await tether.mutation('webhooks.process', body);
  return { received: true };
});

Environment variables

Set your credentials in your .env file:

TETHER_URL=https://tether-api.yourdomain.com
TETHER_PROJECT_ID=your-project-id
TETHER_API_KEY=tthr_live_xxxxxxxxxxxxx

The API key is only available server-side and is never exposed to the client. Use tthr_live_ keys for production and tthr_dev_ keys for development.

Server client vs browser client

The server client uses HTTP-only requests and does not establish WebSocket connections. This makes it lightweight and suitable for serverless environments like Nuxt API routes, Vercel functions, or Cloudflare Workers.

// Available methods on server client:
tether.query(name, args)      // Read data
tether.mutation(name, args)   // Write data
tether.action(name, args)     // External side effects
tether.storage.*              // Full storage API

TypeScript

The SDK is fully typed. Run tthr generate to create types from your schema for full autocomplete on queries and mutations.