SvelteKit-native Convex integration. Real-time queries, form spreading, SSR-to-live transport.
Status: Experimental — actively being tested. Expect breaking changes.
Convex gives you a real-time database. SvelteKit gives you the best full-stack DX in the JavaScript ecosystem. Using them together today means choosing: either you get Convex's live queries or SvelteKit's SSR and form magic. Not both.
With convex-svelte, you get useQuery and that's about it. SSR means manually wiring ConvexHttpClient in load functions, passing initialData, and deriving the final value. Mutations mean calling client.mutation() by hand. No form spreading, no validation, no pending state.
convex-sveltekit gives you both. SvelteKit's DX patterns — but powered by Convex's real-time engine.
Reading data with SSR:
- // +page.server.ts
- const client = new ConvexHttpClient(url)
- return { tasks: await client.query(api.tasks.get, {}) }
-
- // +page.svelte
- const query = useQuery(api.tasks.get, {}, { initialData: data.tasks })
- const tasks = $derived(query.data ?? data.tasks)
+ // +page.ts
+ export const load = () => ({
+ tasks: convexLoad(api.tasks.get, {})
+ })
+
+ // +page.svelte — data.tasks is already live
Mutating data:
- const client = useConvexClient()
- let text = $state("")
- let pending = $state(false)
- async function submit() {
- pending = true
- await client.mutation(api.tasks.create, { text })
- text = ""; pending = false
- }
+ const form = convexForm(z.object({ text: z.string() }), api.tasks.create)
+
+ <form {...form}>
+ <input {...form.fields.text.as("text")} />
+ <button disabled={!!form.pending}>Add</button>
+ </form>
No .refresh() needed. Convex mutations automatically push updates to all live queries.
convexQuery() — live queriesDrop-in component-level query with auto-updating data via WebSocket.
<script>
import { convexQuery } from "convex-sveltekit"
const tasks = convexQuery(api.tasks.get, {})
</script>
{#if tasks.isLoading}
<p>Loading...</p>
{:else}
{#each tasks.data ?? [] as task}
<p>{task.text}</p>
{/each}
{/if}
Supports conditional queries:
const user = convexQuery(api.users.get, () => (userId ? { id: userId } : "skip"))
convexLoad() — SSR with automatic live upgradeUse in SvelteKit load functions. Data is fetched server-side, then seamlessly upgraded to a live WebSocket subscription on the client via SvelteKit's transport hook.
// +page.ts
import { convexLoad } from "convex-sveltekit"
export const load = async () => ({
tasks: await convexLoad(api.tasks.get, {}),
})
<!-- +page.svelte — tasks is reactive, no extra wiring -->
<script>
let { data } = $props()
</script>
{#each data.tasks.data ?? [] as task}
<p>{task.text}</p>
{/each}
SvelteKit preloads it on link hover. Zero loading spinners. Then it's live.
convexForm() — SvelteKit form DX for Convex mutationsMatches the API of SvelteKit's RemoteForm. Spread onto <form>, get field bindings, validation, pending state — but calls Convex mutations directly (no server hop).
<script>
import { convexForm } from "convex-sveltekit"
import { z } from "zod"
const createTask = convexForm(z.object({ text: z.string().min(1) }), api.tasks.create)
</script>
<form {...createTask}>
<input {...createTask.fields.text.as("text")} />
{#each createTask.fields.text.issues?.() ?? [] as issue}
<span class="error">{issue.message}</span>
{/each}
<button disabled={!!createTask.pending}>
{createTask.pending ? "Adding..." : "Add"}
</button>
</form>
Features:
{...form} attaches submit handler via createAttachmentKey.fields.name.as("text") returns typed input attributes.for(id) — parameterized instances for lists.enhance() — custom submit lifecycle.pending — in-flight mutation countconvexCommand() — programmatic mutations/actionsFor mutations (or actions) that don't need a form. Pass "action" as second arg for Convex actions.
const removeTask = convexCommand(api.tasks.remove)
await removeTask({ id: task._id })
const generate = convexCommand(api.ai.generate, "action")
await generate({ prompt: "..." })
setupConvexAuth() + convexUser() — Better Auth integrationFull Better Auth integration with SSR token seeding, cookie-based auth, and live user data.
See BETTER_AUTH.md for the complete setup guide — covers the Convex component, auth proxy, server hooks, client wiring, and working examples.
Alternative:
convex-better-auth-svelteby @mmailaender is an excellent batteries-included adapter with ready-made UI components. If you want to get up and running fast, check that out first. Theconvex-sveltekitauth integration is for when you want the same auth to fitconvexLoad/convexForm/ transport patterns.
Quick taste:
<!-- +layout.svelte -->
<script>
import { setupConvex, setupConvexAuth } from "convex-sveltekit"
let { data } = $props()
setupConvex(PUBLIC_CONVEX_URL)
setupConvexAuth({ authClient, initialToken: data.convexToken })
</script>
// +layout.server.ts — seeds user from JWT, auto-upgrades to live Convex data
export const load = async ({ locals }) => ({
user: convexUser(locals.user),
convexToken: locals.convexToken ?? null,
})
SSR note: Using
.server.tsload files (instead of.ts) for authenticated data currently eliminates rendering flicker completely. Universal.tsfiles work but may flash briefly while the client-side token syncs.
convexLoad() in your load function fetches data server-side via ConvexHttpClienttransport hook serializes the result across the SSR boundarytransport.decode creates a live WebSocket subscription with the SSR data as initial stateconvexLoad() detects the browser and creates the subscription directly.refresh() neededThis means: SSR for first paint, preloading on hover, instant navigation, then real-time forever.
npm install convex-sveltekit convex
# or
pnpm add convex-sveltekit convex
# or
yarn add convex-sveltekit convex
# or
bun add convex-sveltekit convex
// src/hooks.client.ts
import { initConvex } from "convex-sveltekit"
import { PUBLIC_CONVEX_URL } from "$env/static/public"
initConvex(PUBLIC_CONVEX_URL)
// With auth: initConvex(PUBLIC_CONVEX_URL, {}, initialToken)
If you use convexLoad() or serverQuery() in load functions, also add a server hook:
// src/hooks.server.ts
import { initConvex } from "convex-sveltekit"
import { PUBLIC_CONVEX_URL } from "$env/static/public"
initConvex(PUBLIC_CONVEX_URL)
<!-- src/routes/+layout.svelte -->
<script>
import { setupConvex } from "convex-sveltekit"
setupConvex(PUBLIC_CONVEX_URL)
</script>
// src/hooks.ts
import { encodeConvexLoad, decodeConvexLoad } from "convex-sveltekit"
export const transport = {
ConvexLoadResult: {
encode: (value) => encodeConvexLoad(value),
decode: (encoded) => decodeConvexLoad(encoded),
},
}
<script>
import { convexQuery, convexForm } from "convex-sveltekit"
import { api } from "$convex/_generated/api"
import { z } from "zod"
const tasks = convexQuery(api.tasks.get, {})
const addTask = convexForm(z.object({ text: z.string() }), api.tasks.create)
</script>
| Function | Purpose |
|---|---|
initConvex(url, opts?, token?) |
Early client init (hooks.client.ts) |
setupConvex(url) |
Layout init (context + cleanup) |
convexQuery(ref, args, opts?) |
Live query in components |
convexLoad(ref, args) |
SSR query in load functions |
convexForm(schema, mutationRef) |
Form with SvelteKit DX |
convexCommand(ref, type?) |
Programmatic mutation/action |
setupConvexAuth({ authClient, ... }) |
Better Auth ↔ Convex bridge |
useConvexAuth() |
Read auth state (isAuthenticated, etc.) |
convexUser(data) |
SSR-to-live user data transport |
getConvexClient() |
Raw client access (escape hatch) |
useConvexClient() |
Client from Svelte context |
serverQuery(ref, args) |
Server-side one-shot query |
serverMutation(ref, args) |
Server-side one-shot mutation |
serverAction(ref, args) |
Server-side one-shot action |
usePaginatedQuery equivalent)Built by Axel Rock.
Inspired by convex-svelte by the Convex team.
MIT