Skip to content

wvdsh/convex-svelte-1

 
 

Repository files navigation

Community Fork: This is a community-maintained fork of the official convex-svelte package, published as @mmailaender/convex-svelte. It includes additional features like paginated queries and other community-requested improvements. This fork exists as an intermediate solution until the official package is updated.

Convex is the typesafe backend-as-a-service with realtime updates, server functions, crons and scheduled jobs, file storage, vector search, and more.

Quickstart

@mmailaender/convex-svelte

Receive live updates to Convex query subscriptions and call mutations and actions from Svelte with @mmailaender/convex-svelte.

Table of Contents

Svelte (Core)

Everything in this section works in any Svelte app — SvelteKit, Vite + Svelte, or any other setup.

Installation

Install the Convex client and server library:

npm install convex @mmailaender/convex-svelte

Svelte doesn't like referencing code outside of src/, so customize the Convex functions directory. Create a convex.json in your project root:

{
	"functions": "src/convex/"
}

Set up a Convex dev deployment:

npx convex dev

This will prompt you to log in, create a project, and save your deployment URLs. It also creates a src/convex/ folder for your backend API functions.

Setup

Call setupConvex() once in a root layout component (e.g. +layout.svelte). This initializes a ConvexClient, stores it in Svelte context so child components can access it, and automatically closes the connection when the component is destroyed.

<!-- +layout.svelte -->
<script lang="ts">
	import { setupConvex } from '@mmailaender/convex-svelte';
	import { PUBLIC_CONVEX_URL } from '$env/static/public';

	const client = setupConvex(PUBLIC_CONVEX_URL);
</script>

setupConvex() returns the ConvexClient instance, which you can use directly in the layout for mutations or actions (e.g. an auth nav bar). In child components and .ts files, use getConvexClient() to retrieve it — see Client Access.

You can pass ConvexClientOptions as the second argument to configure the client.

Non-SvelteKit usage: If you're using plain Vite + Svelte (no SvelteKit), replace $env/static/public with import.meta.env.VITE_CONVEX_URL and set VITE_CONVEX_URL in your .env file.

Queries

Use useQuery() to subscribe to a Convex query with automatic real-time updates. When the data changes on the server, your component re-renders automatically.

<script lang="ts">
	import { useQuery } from '@mmailaender/convex-svelte';
	import { api } from '../../convex/_generated/api.js';

	const messages = useQuery(api.messages.list, () => ({ muteWords }), {
		keepPreviousData: true
	});
</script>

{#if messages.isLoading}
	Loading...
{:else if messages.error != null}
	failed to load: {messages.error.toString()}
{:else}
	<ul>
		{#each messages.data as message}
			<li>
				<span>{message.author}</span>
				<span>{message.body}</span>
			</li>
		{/each}
	</ul>
{/if}

The returned object is reactive and has the following shape:

Property Type Description
data T | undefined The query result, or undefined while loading
error Error | undefined The error, if the query failed
isLoading boolean true until the first result or error is received
isStale boolean true when displaying cached data from previous arguments

Options

  • initialData — pre-loaded data for SSR/hydration, avoids the loading state (see SSR with initialData)
  • keepPreviousData — when true, keeps displaying the previous result while new data loads after args change

Skipping queries

You can conditionally skip a query by returning 'skip' from the arguments function. This is useful when a query depends on some condition, like authentication state or user input.

<script lang="ts">
	import { useQuery } from '@mmailaender/convex-svelte';
	import { api } from '../convex/_generated/api.js';

	let auth = $state({ isAuthenticated: true });

	const user = useQuery(api.users.queries.getActiveUser, () =>
		auth.isAuthenticated ? {} : 'skip'
	);
</script>

{#if user.isLoading}
	Loading user...
{:else if user.error}
	Error: {user.error}
{:else if user.data}
	Welcome, {user.data.name}!
{/if}

When a query is skipped, isLoading will be false, error will be null, and data will be undefined.

Mutations & Actions

Use useMutation() and useAction() to get callable functions for your Convex mutations and actions. Both use the module-level singleton (getConvexClient()) internally, so they work in .svelte components and plain .ts / .js files — anywhere after setupConvex() has been called.

<script lang="ts">
	import { useMutation } from '@mmailaender/convex-svelte';
	import { api } from '../../convex/_generated/api.js';

	const sendMessage = useMutation(api.messages.send);

	let toSend = $state('');
	let author = $state('me');

	function handleSubmit(event: SubmitEvent) {
		event.preventDefault();

		const data = Object.fromEntries(new FormData(event.target as HTMLFormElement).entries());
		sendMessage({
			author: data.author as string,
			body: data.body as string
		});
	}
</script>

<form onsubmit={handleSubmit}>
	<input type="text" name="author" bind:value={author} />
	<input type="text" name="body" bind:value={toSend} />
	<button type="submit" disabled={!toSend}>Send</button>
</form>

Actions are similar to mutations but can have side effects like calling third-party APIs:

import { useAction } from '@mmailaender/convex-svelte';
import { api } from '../../convex/_generated/api.js';

const generateUploadUrl = useAction(api.files.generateUploadUrl);
const uploadUrl = await generateUploadUrl({});

Optimistic updates

Optimistic updates let you update the UI immediately when a mutation is called, without waiting for the server to respond. Pass an optimisticUpdate callback in the mutation options at the call site to update the local query cache.

<script lang="ts">
	import { useMutation } from '@mmailaender/convex-svelte';
	import { api } from '../../convex/_generated/api.js';

	const updateUser = useMutation(api.user.update);

	async function handleUpdate() {
		await updateUser(
			{ name: 'John Doe' },
			{
				optimisticUpdate: (store) => {
					store.setQuery(api.user.get, {}, { name: 'John Doe' });
				}
			}
		);
	}
</script>

Inside the optimisticUpdate callback, use store.setQuery() to update the local cache for a specific query. The arguments are:

  1. Query reference — the query to update (e.g. api.user.get)
  2. Query arguments — must match the arguments used by the active useQuery() subscription
  3. New value — the optimistic data to display immediately

If the mutation fails, the optimistic update is automatically rolled back and the UI reverts to the server state.

Client Access

getConvexClient() — universal client access

getConvexClient() retrieves the client from a module-level singleton. It works anywhere — .svelte components, plain .ts utility files, service layers, async callbacks — as long as setupConvex() has been called first.

This is the recommended way to access the client outside of the layout where setupConvex() returns it directly.

useConvexClient() — Svelte context alternative

useConvexClient() retrieves the same client from Svelte context via getContext(). It only works during component initialization — inside .svelte files or code called synchronously from a component's <script> block. Both functions return the same ConvexClient instance.

getConvexClient() useConvexClient()
Works in Anywhere (.ts, .svelte, hooks) Svelte components only
Mechanism Module singleton Svelte getContext()

Using mutations in utility files

useMutation() and useAction() work in plain .ts files too, since they use the module-level singleton:

// src/lib/services/tasks.ts
import { useMutation } from '@mmailaender/convex-svelte';
import { api } from '../convex/_generated/api.js';

const createTaskMutation = useMutation(api.tasks.create);
const completeTaskMutation = useMutation(api.tasks.complete);

export async function createTask(text: string) {
	await createTaskMutation({ text });
}

export async function completeTask(id: string) {
	await completeTaskMutation({ id });
}

Then call these functions from any component without plumbing the client through:

<script lang="ts">
	import { createTask } from '$lib/services/tasks.js';

	let text = $state('');
</script>

<form
	onsubmit={(e) => {
		e.preventDefault();
		createTask(text);
		text = '';
	}}
>
	<input bind:value={text} />
	<button type="submit">Add</button>
</form>

For advanced use cases like one-time queries, use getConvexClient() directly:

import { getConvexClient } from '@mmailaender/convex-svelte';
import { api } from '../convex/_generated/api.js';

// One-time query (no WebSocket subscription, just a single fetch)
export async function getTaskCount() {
	const client = getConvexClient();
	return await client.query(api.tasks.count, {});
}

Note: The .svelte.ts file extension enables Svelte 5 runes ($state, $derived, $effect) but does not make getContext() work outside components. If you need the client in a plain .ts file, use getConvexClient(), not useConvexClient().

Paginated Queries

For queries that return large datasets, use usePaginatedQuery() to load results incrementally. This hook manages cursor-based pagination automatically and provides a loadMore function to fetch additional pages.

<script lang="ts">
	import { usePaginatedQuery } from '@mmailaender/convex-svelte';
	import { api } from '../../convex/_generated/api.js';

	const paginatedMessages = usePaginatedQuery(api.messages.listPaginated, () => ({}), {
		initialNumItems: 10
	});
</script>

{#if paginatedMessages.isLoading}
	Loading...
{:else if paginatedMessages.error}
	Error: {paginatedMessages.error.toString()}
{:else}
	<ul>
		{#each paginatedMessages.results as message}
			<li>
				<span>{message.author}</span>
				<span>{message.body}</span>
			</li>
		{/each}
	</ul>
	{#if paginatedMessages.status === 'CanLoadMore'}
		<button onclick={() => paginatedMessages.loadMore(10)}>Load more</button>
	{/if}
{/if}

Options

  • initialNumItems (required) — number of items to load on the first page
  • initialData — optional initial data for SSR/hydration
  • keepPreviousData — when true, keeps previous results visible while loading new data after args change

You can also skip a paginated query by returning 'skip' from the arguments function, just like with useQuery().

<script lang="ts">
	import { usePaginatedQuery } from '@mmailaender/convex-svelte';
	import { api } from '../../convex/_generated/api.js';

	let searchTerm = $state('');

	const searchResults = usePaginatedQuery(
		api.messages.search,
		() => (searchTerm.length > 0 ? { query: searchTerm } : 'skip'),
		{ initialNumItems: 20, keepPreviousData: true }
	);
</script>

Async Queries (experimental)

Pass { async: true } to useQuery() to return a PromiseLike that works with Svelte's await keyword and <svelte:boundary> for declarative loading and error states.

Note: This requires Svelte's experimental async support. Add the following to your svelte.config.js:

compilerOptions: {
	experimental: {
		async: true;
	}
}
<script lang="ts">
	import { useQuery } from '@mmailaender/convex-svelte';
	import { api } from '../convex/_generated/api.js';

	const messages = useQuery(api.messages.list, () => ({ muteWords: [] }), { async: true });
	const user = useQuery(api.users.getActive, {}, { async: true });
</script>

<svelte:boundary>
	{#snippet pending()}
		<p>Loading...</p>
	{/snippet}

	{#snippet failed(error, reset)}
		<p>Something went wrong: {(error as Error).message}</p>
		<button onclick={reset}>Retry</button>
	{/snippet}

	{@const msgs = await messages}
	{@const me = await user}

	<h2>Welcome, {me.data.name}!</h2>
	<ul>
		{#each msgs.data as message}
			<li>{message.author}: {message.body}</li>
		{/each}
	</ul>
</svelte:boundary>

The <svelte:boundary> handles both loading and error states declaratively — no {#if isLoading} / {:else if error} / {:else} chains. Multiple queries can share a single boundary, so the pending snippet shows until all queries resolve.

Error handling: The boundary fully covers errors — both during the initial load (promise rejects → failed snippet) and after data has arrived (e.g. auth expiry, network issues). The data getter throws errors so the boundary catches them as rendering errors automatically. Clicking the Retry button re-renders the content, picking up recovered data if the subscription has reconnected.

Loading states: The boundary's pending snippet covers the initial load. For subsequent loading after reactive arg changes, the boundary does not re-enter pending (this is Svelte's design). Use keepPreviousData: true to display stale data during transitions, with isStale as a visual indicator:

{@const result = await messages}

{#if result.isStale}
	<p>Updating...</p>
{/if}

<ul>
	{#each result.data as message}
		<li>{message.author}: {message.body}</li>
	{/each}
</ul>

When to use async vs sync

  • Use useQuery() (default sync) when you want inline control over loading/error states, or need to render partial UI while data loads.
  • Use useQuery() with { async: true } when you want boundary-based loading/error handling with less markup. This shines when grouping multiple queries under a single boundary. With Svelte 6's async renderer, this will also enable SSR without +page.server.ts boilerplate.

All options (initialData, keepPreviousData, skip) work in both modes.

Authentication

setupAuth / useAuth

setupAuth() accepts a reactive getter returning the auth provider's state and automatically manages client.setAuth() / client.clearAuth(). This mirrors React's ConvexProviderWithAuth — when the provider state changes (sign-in, sign-out, token refresh), the auth lifecycle updates automatically.

<!-- +layout.svelte -->
<script lang="ts">
	import { setupConvex, setupAuth } from '@mmailaender/convex-svelte';
	import { PUBLIC_CONVEX_URL } from '$env/static/public';

	setupConvex(PUBLIC_CONVEX_URL);

	// The getter is reactive — when its return values change,
	// setupAuth automatically toggles setAuth/clearAuth.
	setupAuth(() => ({
		isLoading: false,
		isAuthenticated: !!session,
		fetchAccessToken: async ({ forceRefreshToken }) => {
			if (!session) return null;
			return await getTokenFromYourAuthProvider({ forceRefreshToken });
		}
	}));
</script>

useAuth() reads the resulting state in any child component:

<script lang="ts">
	import { useAuth, useQuery } from '@mmailaender/convex-svelte';
	import { api } from '../convex/_generated/api.js';

	const auth = useAuth();

	const user = useQuery(api.users.getActive, () => (auth.isAuthenticated ? {} : 'skip'));
</script>

{#if auth.isLoading}
	Checking authentication...
{:else if !auth.isAuthenticated}
	Please sign in.
{:else}
	Welcome, {user.data?.name}!
{/if}

When the auth provider's isAuthenticated changes from true to false (user signs out), the internal $effect re-runs, calls clearAuth() automatically, and useAuth().isAuthenticated updates to false. No manual cleanup needed.

SSR initial state

Pass initialState to seed the server render before any client-side $effect runs:

<script lang="ts">
	import { setupConvex, setupAuth } from '@mmailaender/convex-svelte';

	let { data } = $props(); // from +layout.server.ts

	setupConvex(PUBLIC_CONVEX_URL);
	setupAuth(
		() => ({
			isLoading: session.isPending,
			isAuthenticated: !!session.data,
			fetchAccessToken: async ({ forceRefreshToken }) => getToken({ forceRefreshToken })
		}),
		{ initialState: { isAuthenticated: data.isAuthenticated } }
	);
</script>

The server state is trusted until the client-side auth flow settles, then the client takes over.

Auth adapters

For a complete authentication setup with Better Auth, see @mmailaender/convex-better-auth-svelte. Its createSvelteAuthClient() calls setupAuth() internally with a reactive session getter, so useAuth() from either package works.

Low-level: client.setAuth()

You can also use client.setAuth() directly for custom integrations:

<script lang="ts">
	import { useConvexClient } from '@mmailaender/convex-svelte';

	const client = useConvexClient();

	client.setAuth(
		async () => {
			return await getAuthToken();
		},
		(isAuthenticated) => {
			console.log('Auth state changed:', isAuthenticated);
		}
	);
</script>

SvelteKit

This section builds on Svelte (Core). Make sure setupConvex() is in your root layout before using these features.

Import from @mmailaender/convex-svelte/sveltekit for SvelteKit-specific features: SSR transport with live upgrade, and a server-side HTTP client helper.

New to SSR with Convex? See Why SSR with Convex? for a detailed comparison of SSR vs client-side rendering performance.

SSR with convexLoad / convexLoadPaginated (recommended)

convexLoad() and convexLoadPaginated() fetch data on the server and automatically upgrade to live subscriptions on the client. No manual initialData wiring needed. Use convexLoad() for regular queries and convexLoadPaginated() for paginated queries.

Setup

Add initConvex() and the transport hooks to hooks.ts (universal hooks — runs on both server and client). initConvex() creates the ConvexClient singleton early so the transport decoder can upgrade SSR data to live subscriptions. setupConvex() in your root layout automatically reuses this singleton.

If you only use convexLoad(), you only need the ConvexLoadResult transport. Add ConvexLoadPaginatedResult when using convexLoadPaginated().

// hooks.ts
import {
	initConvex,
	encodeConvexLoad,
	decodeConvexLoad,
	encodeConvexLoadPaginated,
	decodeConvexLoadPaginated
} from '@mmailaender/convex-svelte/sveltekit';
import { PUBLIC_CONVEX_URL } from '$env/static/public';

initConvex(PUBLIC_CONVEX_URL);

export const transport = {
	ConvexLoadResult: {
		encode: encodeConvexLoad,
		decode: decodeConvexLoad
	},
	// Only needed if you use convexLoadPaginated()
	ConvexLoadPaginatedResult: {
		encode: encodeConvexLoadPaginated,
		decode: decodeConvexLoadPaginated
	}
};

Usage with convexLoad

// +page.ts (universal load function)
import { convexLoad } from '@mmailaender/convex-svelte/sveltekit';
import { api } from '$convex/_generated/api';

export const load = async () => ({
	tasks: await convexLoad(api.tasks.get, {})
});
<!-- +page.svelte -->
<script lang="ts">
	let { data } = $props();
	const tasks = $derived(data.tasks);
</script>

{#if tasks.isLoading}
	Loading...
{:else if tasks.error}
	Error: {tasks.error.message}
{:else}
	<ul>
		{#each tasks.data as task}
			<li>{task.text}</li>
		{/each}
	</ul>
{/if}

The result has the same shape as useQuery().data, .isLoading, .error, .isStale — and is reactive. On first load, data arrives via SSR (no loading flash). After hydration, a live WebSocket subscription takes over automatically.

Usage with convexLoadPaginated

convexLoadPaginated() works the same way but for paginated queries. It fetches the first page on the server and upgrades to a live paginated subscription on the client — with loadMore() support for incremental loading.

// +page.ts (universal load function)
import { convexLoadPaginated } from '@mmailaender/convex-svelte/sveltekit';
import { api } from '$convex/_generated/api';

export const load = async () => ({
	messages: await convexLoadPaginated(
		api.messages.paginatedList,
		{ muteWords: [] },
		{ initialNumItems: 10 }
	)
});
<!-- +page.svelte -->
<script lang="ts">
	let { data } = $props();
	const messages = $derived(data.messages);
</script>

{#if messages.isLoading}
	Loading...
{:else if messages.error}
	Error: {messages.error.message}
{:else}
	<ul>
		{#each messages.results as message}
			<li>{message.author}: {message.body}</li>
		{/each}
	</ul>
	{#if messages.status === 'CanLoadMore'}
		<button onclick={() => messages.loadMore(10)}>Load more</button>
	{/if}
{/if}

The result has the same shape as usePaginatedQuery().results, .status, .isLoading, .error, .loadMore() — and is reactive. On first load, the first page arrives via SSR (no loading flash). After hydration, a live WebSocket subscription takes over and loadMore() becomes functional.

Authenticated fetches

For authenticated SSR fetches, use withServerConvexToken in your server hook. This stores the auth token per-request via AsyncLocalStorage, so convexLoad and createConvexHttpClient pick it up automatically — no { token } option needed.

// hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { withServerConvexToken } from '@mmailaender/convex-svelte/sveltekit/server';

export const handle: Handle = async ({ event, resolve }) => {
	const token = await getAuthToken(event.cookies); // your auth provider
	event.locals.token = token;
	return withServerConvexToken(token, () => resolve(event));
};

Then use convexLoad in any load function — +page.ts or +page.server.ts:

// +page.ts (universal) — works for both SSR and client-side navigation
import { convexLoad } from '@mmailaender/convex-svelte/sveltekit';
import { api } from '$convex/_generated/api';

export const load = async () => ({
	tasks: await convexLoad(api.tasks.get, {})
});

The explicit { token } option still works as a manual override:

// +page.server.ts — explicit token (escape hatch)
export const load = async ({ locals }) => ({
	tasks: await convexLoad(api.tasks.get, {}, { token: locals.token })
});

SSR with initialData (manual alternative)

If you prefer server-only load functions (+page.server.ts) or need more control, you can use the initialData option on useQuery() and usePaginatedQuery() directly.

// +page.server.ts
import { ConvexHttpClient } from 'convex/browser';
import type { PageServerLoad } from './$types.js';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
import { api } from '../convex/_generated/api.js';

export const load = (async () => {
	const client = new ConvexHttpClient(PUBLIC_CONVEX_URL!);
	return {
		messages: await client.query(api.messages.list, { muteWords: [] })
	};
}) satisfies PageServerLoad;
<script lang="ts">
	// +page.svelte
	import type { PageData } from './$types.js';
	let { data }: { data: PageData } = $props();

	import { useQuery } from '@mmailaender/convex-svelte';
	import { api } from '../convex/_generated/api.js';

	const messages = useQuery(
		api.messages.list,
		() => ({ muteWords: [] }),
		() => ({ initialData: data.messages })
	);
</script>

Combining initialData with keepPreviousData: true (or never changing the query arguments) should be enough to avoid ever seeing a loading state.

When to use this over convexLoad: Use initialData when building a library that needs to support Svelte-only, SvelteKit SPA, and SvelteKit SSR without requiring the transport hook setup.

Server Helpers

withServerConvexToken (recommended)

Import from @mmailaender/convex-svelte/sveltekit/server. Wraps your SvelteKit resolve() call to store the auth token per-request via AsyncLocalStorage. Both convexLoad and createConvexHttpClient automatically read it during SSR.

// hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { withServerConvexToken } from '@mmailaender/convex-svelte/sveltekit/server';

export const handle: Handle = async ({ event, resolve }) => {
	const token = await getAuthToken(event.cookies);
	event.locals.token = token; // still available for direct use
	return withServerConvexToken(token, () => resolve(event));
};
// app.d.ts
declare global {
	namespace App {
		interface Locals {
			token: string | undefined;
		}
	}
}

With this setup, convexLoad() and createConvexHttpClient() automatically authenticate during SSR — no { token } option needed in load functions.

Setting up locals.token (without withServerConvexToken)

If you prefer not to use withServerConvexToken, you can still extract the token and pass it explicitly:

// hooks.server.ts
import type { Handle } from '@sveltejs/kit';

export const handle: Handle = async ({ event, resolve }) => {
	event.locals.token = await getAuthToken(event.cookies);
	return resolve(event);
};

Then pass { token: locals.token } to convexLoad or createConvexHttpClient in each load function.

createConvexHttpClient

For server-only code (+page.server.ts, form actions, API routes), use createConvexHttpClient():

// +page.server.ts — with withServerConvexToken (no args needed)
import { createConvexHttpClient } from '@mmailaender/convex-svelte/sveltekit';
import { api } from '$convex/_generated/api';

export const load = async () => {
	const client = createConvexHttpClient();
	const tasks = await client.query(api.tasks.get, {});
	return { tasks };
};

Explicit token still works as an override:

// +page.server.ts — explicit token (escape hatch)
export const load = async ({ locals }) => {
	const client = createConvexHttpClient({ token: locals.token });
	const tasks = await client.query(api.tasks.get, {});
	return { tasks };
};

The url option falls back to the URL set by initConvex().

Deploying

See the Convex deployment guide for detailed instructions on deploying your app and Convex functions to production.

Troubleshooting

effect_in_teardown Error

If you encounter effect_in_teardown errors when using useQuery in components that can be conditionally rendered (like dialogs, modals, or popups), this is caused by wrapping useQuery in a $derived block that depends on reactive state.

When useQuery is wrapped in $derived, state changes during component cleanup can trigger re-evaluation of the $derived, which attempts to create a new useQuery instance. Since useQuery internally creates a $effect, and effects cannot be created during cleanup, this throws an error.

Use Skipping queries instead. By calling useQuery unconditionally at the top level and passing a function that returns 'skip', the function is evaluated inside useQuery's own effect tracking, preventing query recreation during cleanup.

Missing setupConvex() Error

If you see No ConvexClient was found in Svelte context, make sure setupConvex() is called in a parent layout or component (e.g. +layout.svelte) before any child component calls useQuery() or useConvexClient().

String query names

Query references must be api.* function references, not plain strings. If you pass a string like "messages.list", you will get an error. Always import and use api from your generated API.

Why SSR with Convex?

With a realtime backend like Convex, you might wonder whether SSR is worth the effort — after all, the client will open a WebSocket and get live updates anyway. The short answer: SSR with Convex is almost always faster for time-to-data on first page load.

The client-side waterfall

Without SSR, every first page load hits a sequential waterfall:

1. Client → Framework server: request page
2. Framework server → Client: HTML shell (empty)        ← skeleton visible
3. Browser parses HTML, discovers <script> tags
4. Browser downloads JavaScript bundle(s)
5. Browser parses + executes JavaScript                  ← 50-200ms on mobile
6. Framework boots, component mounts, useQuery() fires
7. Client → Convex: subscribe to query
8. Convex → Client: data                                ← content visible

Steps 3–6 are dead time — the user is staring at a skeleton while the browser downloads and executes JS before it can even start talking to Convex.

SSR eliminates the waterfall

With SSR, your framework server fetches data from Convex while rendering the page. The client receives complete HTML with data in a single response:

1. Client → Framework server: request page
2. Server → Convex: fetch data                           ← ~1-5ms if co-located
3. Server renders HTML with data
4. Framework server → Client: complete HTML + data       ← content visible
5. Browser hydrates (attaches event listeners)
6. Client → Convex: WebSocket for live updates           ← background, non-blocking

The server uses the time the client would be waiting anyway (for the HTTP response) to productively fetch data. Steps 2–3 happen inside the server response time, not after it.

How much faster?

The difference depends on three factors:

Device speed — The biggest variable. On a mid-range mobile phone, JS parse + execute (steps 3–6 in the waterfall) takes 100–300ms. On desktop, 30–80ms. SSR skips this entirely.

Server-to-Convex distance — If your framework server is co-located with Convex (same cloud region), the server→Convex hop is ~1–5ms. This is essentially free. Convex currently offers US East (N. Virginia) and EU West (Ireland) regions.

Client-to-server distance — Both approaches need at least one round trip to the framework server. SSR bundles data into that response; client-side adds a second round trip to Convex after JS execution.

A realistic example (user in Germany, framework server in EU, Convex in Ireland):

SSR Client-side
Skeleton/shell visible ~20ms
Content with data visible ~70ms ~200–400ms
Live updates active ~150ms (background) ~200–400ms

Co-locate your server with Convex

The single biggest optimization: deploy your framework server in the same region as Convex.

Platform How to co-locate with Convex
Vercel Set function region to iad1 (US East) or dub1 (Ireland) in project settings — default is iad1
Cloudflare Enable Smart Placement or set explicit placement to match your Convex region
Netlify Use region selection to match your Convex region

With co-location, the server→Convex hop is negligible (~1–5ms), and SSR becomes strictly faster than client-side for time-to-data.

SSR is easy with the SvelteKit transport hook

A common concern is that SSR adds boilerplate. With the convexLoad transport hook, it's minimal — fetch in your load function, use the result directly in the template. No manual initialData wiring needed:

// +page.ts
export const load = async () => ({
	tasks: await convexLoad(api.tasks.get, {})
});

The result is a live-updating reactive object that works without useQuery() in the component. SSR on first load, live WebSocket updates after hydration — all handled automatically.

When is client-side rendering acceptable?

SSR delivers a better experience in virtually every scenario. Client-side rendering is not faster — it just shows a skeleton sooner while the user waits longer for actual data. That said, skipping SSR is acceptable when:

  • Authenticated app-like UIs (dashboards, admin panels) — users have longer sessions where the one-time initial load cost is amortized, and SEO is irrelevant
  • Rapid prototyping — when you want to iterate quickly and add SSR later

Even in these cases, SSR would still provide a faster first load. The trade-off is development effort, not performance.

Note that subsequent navigations are always client-side regardless of your SSR choice. After the initial page load, SvelteKit does client-side routing and the Convex WebSocket is already open — data loads without any framework server round trip.

Recommendation: Default to SSR. It is faster for time-to-data in every realistic deployment, and with the transport hook it requires minimal effort. Only skip SSR if you have a specific reason to.

API Reference

convex-svelte exports

Import from @mmailaender/convex-svelte:

Export Kind Description
setupConvex(url, options?) Function Initialize the Convex client and store it in Svelte context. Call once in a root layout. Returns ConvexClient.
useConvexClient() Function Retrieve the ConvexClient from Svelte context. Must be called during component initialization.
getConvexClient() Function Retrieve the ConvexClient module singleton. Works anywhere — no Svelte context needed.
useQuery(query, args, options?) Function Subscribe to a Convex query with reactive updates. Returns UseQueryReturn.
UseQueryOptions<Query> Type Options for useQuery: initialData, keepPreviousData, async.
UseQueryReturn<Query> Type Return type of useQuery: data, error, isLoading, isStale.
usePaginatedQuery(query, args, options) Function Subscribe to a paginated Convex query with cursor management. Returns UsePaginatedQueryReturn.
UsePaginatedQueryOptions<Query> Type Options for usePaginatedQuery: initialNumItems, initialData, keepPreviousData.
UsePaginatedQueryReturn<Query> Type Return type of usePaginatedQuery: results, status, isLoading, loadMore, error.
setupAuth(provider, options?) Function Set up reactive authentication. Manages setAuth/clearAuth automatically.
ConvexAuthProvider Type Auth provider state: isLoading, isAuthenticated, fetchAccessToken.
SetupAuthOptions Type Options for setupAuth: initialState for SSR hydration.
useAuth() Function Read auth state (isLoading, isAuthenticated) from context.
UseAuthReturn Type Return type of useAuth: isLoading, isAuthenticated.

convex-svelte/sveltekit exports

Import from @mmailaender/convex-svelte/sveltekit:

Export Kind Description
initConvex(url, options?) Function Create the ConvexClient singleton early. Only needed for convexLoad SSR setup.
getConvexUrl() Function Retrieve the deployment URL set by initConvex() or setupConvex().
convexLoad(query, args, options?) Function Fetch data server-side, upgrade to live subscription on client.
encodeConvexLoad Function Transport encoder — use in hooks.ts (see convexLoad Setup).
decodeConvexLoad Function Transport decoder — use in hooks.ts (see convexLoad Setup).
convexLoadPaginated(query, args, options) Function Fetch first page server-side, upgrade to live paginated subscription on client.
encodeConvexLoadPaginated Function Paginated transport encoder — use in hooks.ts.
decodeConvexLoadPaginated Function Paginated transport decoder — use in hooks.ts.
createConvexHttpClient(options?) Function Create a ConvexHttpClient for server-side use.
CreateConvexHttpClientOptions Type Options for createConvexHttpClient: url, token, options.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages

  • TypeScript 71.0%
  • Svelte 25.9%
  • JavaScript 2.9%
  • Other 0.2%