SvelteKit
Install and configure Convex + Better Auth for SvelteKit.
SvelteKit support is currently community maintained, and relies on the @mmailaender/convex-better-auth-svelte package. A complete working example is provided in the repo, and any issues can be reported there as well.
This guide targets SvelteKit 3. Still on SvelteKit 2? Follow along and apply the differences listed in SvelteKit 2.
Prerequisites
You'll first need a project where Convex is already set-up. Ensure that all steps in prerequisites are completed.
Install convex
npm install convex convex-svelteCustomize the convex path
SvelteKit doesn't like referencing code outside of source, so customize the convex functionsDir to be under src/.
{
"functions": "src/convex/"
}Set up a Convex dev deployment
Next, run npx convex dev. This will prompt you to log in with GitHub, create a project, and save your production and deployment URLs.
It will also create a src/convex/ folder for you to write your backend API functions in. The dev command will then continue running to sync your functions with your dev deployment in the cloud.
npx convex devAdd a #convex import alias
Add a subpath import to your package.json, so you can import from src/convex/ as #convex/...:
{
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*",
"#convex/*": "./src/convex/*"
}
}Installation
Install packages
Install the component, a pinned version of Better Auth, and ensure the latest version of Convex.
1.25.0 or later.npm install @convex-dev/better-auth @mmailaender/convex-better-auth-svelte
npm install better-auth@~1.6.15Register the component
Register the Better Auth component in your Convex project.
import { defineApp } from "convex/server";
import betterAuth from "@convex-dev/better-auth/convex.config";
const app = defineApp();
app.use(betterAuth);
export default app;Add Convex auth config
Add a src/convex/auth.config.ts file to configure Better Auth as an authentication provider.
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;Set environment variables
Generate a secret for encryption and generating hashes. Use the command below if you have openssl installed, or generate your own however you like.
npx convex env set BETTER_AUTH_SECRET=$(openssl rand -base64 32)Add your site URL to your Convex deployment.
npx convex env set SITE_URL http://localhost:5173Add environment variables to the .env.local file created by npx convex dev. It will be picked up by your framework dev server.
# Deployment used by \`npx convex dev\`
CONVEX_DEPLOYMENT=dev:adjective-animal-123 # team: team-name, project: project-name
PUBLIC_CONVEX_URL=https://adjective-animal-123.convex.cloud
# Your local site URL
PUBLIC_SITE_URL=http://localhost:5173On Convex Cloud, the Convex site URL (.convex.site) is derived from PUBLIC_CONVEX_URL.
Self-hosted, local, and custom-domain deployments need PUBLIC_CONVEX_SITE_URL.
Declare the variables in src/env.ts. SvelteKit 3 only exposes declared variables.
import { defineEnvVars } from '@sveltejs/kit/env';
export const variables = defineEnvVars({
PUBLIC_CONVEX_URL: { public: true, static: true }
});static: true inlines the values at build time. Keep it if the values only
exist while building, for example with npx convex deploy --cmd 'npm run build'.
Create a Better Auth instance
Create a Better Auth instance and initialize the component.
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import { components } from "./_generated/api";
import { type DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import { betterAuth, type BetterAuthOptions } from "better-auth/minimal";
import authConfig from "./auth.config";
const siteUrl = process.env.SITE_URL!;
// The component client has methods needed for integrating Convex with Better Auth,
// as well as helper methods for general use.
export const authComponent = createClient<DataModel>(components.betterAuth);
export const createAuth = (ctx: GenericCtx<DataModel>) => {
return betterAuth({
baseURL: siteUrl,
database: authComponent.adapter(ctx),
// Configure simple, non-verified email/password to get started
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [
// The Convex plugin is required for Convex compatibility
convex({ authConfig }),
],
})
}
// Example function for getting the current user
// Feel free to edit, omit, etc.
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.getAuthUser(ctx);
},
});Create a Better Auth client instance
Create a Better Auth client instance for interacting with the Better Auth server from your client.
import { createAuthClient } from 'better-auth/svelte';
import { convexClient } from "@convex-dev/better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [convexClient()],
});Mount handlers
Register Better Auth route handlers on your Convex deployment.
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
const http = httpRouter();
authComponent.registerRoutes(http, createAuth);
export default http;Set up route handlers to proxy auth requests from your framework server to your Convex deployment.
import { createSvelteKitHandler } from '@mmailaender/convex-better-auth-svelte/sveltekit';
export const { GET, POST } = createSvelteKitHandler();Initialize the Convex client
Register your deployment URL once in the universal hooks file, which runs on the server and in the browser.
createSvelteAuthClient, createConvexHttpClient, and createSvelteKitHandler all read it from there.
import { initConvex } from 'convex-svelte/sveltekit';
import { PUBLIC_CONVEX_URL } from '$app/env/public';
initConvex(PUBLIC_CONVEX_URL);Set up Convex client provider
Initialize the Convex client with auth in your app.
Note: createSvelteAuthClient already calls setupConvex() from convex-svelte, reusing the client from initConvex().
<script lang="ts">
import '../app.css';
import favicon from '#lib/assets/favicon.svg';
import { createSvelteAuthClient } from '@mmailaender/convex-better-auth-svelte/svelte';
import { authClient } from '#lib/auth-client.js';
createSvelteAuthClient({ authClient });
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}Set up authentication token for server-side code
To use Better Auth in server load functions, form actions, and other server-side code, you need to extract the authentication token from cookies and make it available throughout your server code.
Set up a SvelteKit hook to automatically extract the auth token on every request. withServerConvexToken stores the token per-request via AsyncLocalStorage, so convexLoad and createConvexHttpClient automatically pick it up during SSR — no need to pass { token } in every load function.
import type { Handle } from "@sveltejs/kit/hooks";
import { getToken } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { withServerConvexToken } from 'convex-svelte/sveltekit/server';
export const handle: Handle = async ({ event, resolve }) => {
const token = getToken(event.cookies);
event.locals.token = token;
return withServerConvexToken(token, () => resolve(event));
};Prefer getToken(event.cookies) over the legacy
getToken(createAuth, event.cookies) overload. The cookie-only helper
avoids constructing createAuth() in the SvelteKit SSR runtime, which is
important for split-runtime or reverse-proxy deployments.
If your Better Auth config sets advanced.cookiePrefix, pass the same prefix
so the token cookie is found: getToken(event.cookies, { cookiePrefix: 'myapp' }).
Add the token type to your app's type definitions. This enables TypeScript support for event.locals.token in your server code.
declare global {
namespace App {
interface Locals {
token: string | undefined;
}
}
}With this setup, convexLoad() and createConvexHttpClient() automatically authenticate during SSR. event.locals.token is also available for direct use in server load functions and form actions. See Using Better Auth from the server for examples.
You're done!
You're now ready to start using Better Auth with Convex.
Usage
Check out the Basic Usage guide for more information on general usage. Below are usage notes specific to SvelteKit.
Using Better Auth from the client
<script lang="ts">
import { authClient } from '#lib/auth-client.js';
import { api } from '#convex/_generated/api.js';
import { useQuery } from 'convex-svelte';
import { useAuth } from '@mmailaender/convex-better-auth-svelte/svelte';
let { data } = $props();
// Auth state store
const auth = useAuth();
const isLoading = $derived(auth.isLoading);
const isAuthenticated = $derived(auth.isAuthenticated);
const currentUserResponse = useQuery(api.auth.getCurrentUser, () => (isAuthenticated ? {} : 'skip'));
let user = $derived(currentUserResponse.data);
// Sign in/up form state
let showSignIn = $state(true);
let name = $state('');
let email = $state('');
let password = $state('');
// Handle form submission
async function handlePasswordSubmit(event: Event) {
event.preventDefault();
try {
if (showSignIn) {
await authClient.signIn.email(
{ email, password },
{
onError: (ctx) => {
alert(ctx.error.message);
}
}
);
} else {
await authClient.signUp.email(
{ name, email, password },
{
onError: (ctx) => {
alert(ctx.error.message);
}
}
);
}
} catch (error) {
console.error('Authentication error:', error);
}
}
// Sign out function
async function signOut() {
const result = await authClient.signOut();
if(result.error) {
console.error('Sign out error:', result.error);
}
}
// Toggle between sign in and sign up
function toggleSignMode() {
showSignIn = !showSignIn;
// Clear form fields when toggling
name = '';
email = '';
password = '';
}
// Demo: Fetch access token
let accessToken = $state<string | null>(null);
let tokenLoading = $state(false);
async function fetchToken() {
tokenLoading = true;
try {
const token = await auth.fetchAccessToken({ forceRefreshToken: true });
accessToken = token;
} catch (error) {
console.error('Error fetching access token:', error);
accessToken = 'Error fetching token';
} finally {
tokenLoading = false;
}
}
</script>
<div class="flex h-screen flex-col items-center justify-center bg-gray-50">
{#if isLoading}
<div class="text-lg text-gray-600">Loading...</div>
{:else if !isAuthenticated}
<!-- Sign In Component -->
<div class="flex w-full max-w-md flex-col gap-4 rounded-lg bg-white p-6 shadow-md">
<h2 class="mb-6 text-center text-2xl font-bold text-gray-800">
{showSignIn ? 'Sign In' : 'Sign Up'}
</h2>
<form onsubmit={handlePasswordSubmit} class="flex flex-col gap-4">
{#if !showSignIn}
<input
bind:value={name}
placeholder="Name"
required
class="rounded-md border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
{/if}
<input
type="email"
bind:value={email}
placeholder="Email"
required
class="rounded-md border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<input
type="password"
bind:value={password}
placeholder="Password"
required
class="rounded-md border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 focus:outline-none"
/>
<button
type="submit"
class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none"
>
{showSignIn ? 'Sign in' : 'Sign up'}
</button>
</form>
<p class="mt-4 text-center text-gray-600">
{showSignIn ? "Don't have an account? " : 'Already have an account? '}
<button
type="button"
onclick={toggleSignMode}
class="cursor-pointer border-none bg-transparent text-blue-600 underline hover:text-blue-800"
>
{showSignIn ? 'Sign up' : 'Sign in'}
</button>
</p>
</div>
{:else if isAuthenticated}
<!-- Dashboard Component -->
<div class="w-full max-w-md rounded-lg bg-white p-6 text-center shadow-md">
<div class="mb-4 text-xl font-semibold text-gray-800">
Hello {user?.name}!
</div>
<!-- Demo: Access Token Section -->
<div class="mb-4 rounded-md bg-gray-50 p-4">
<h3 class="mb-2 text-sm font-medium text-gray-700">Access Token Demo</h3>
<button
onclick={fetchToken}
disabled={tokenLoading}
class="cursor-pointer rounded-md bg-blue-600 px-3 py-1 text-sm text-white transition-colors hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
>
{tokenLoading ? 'Fetching...' : 'Fetch Access Token'}
</button>
{#if accessToken}
<div class="mt-2 rounded border bg-white p-2 text-xs break-all text-gray-600">
{accessToken.length > 50 ? accessToken.substring(0, 50) + '...' : accessToken}
</div>
{/if}
</div>
<button
onclick={signOut}
class="cursor-pointer rounded-md bg-red-600 px-4 py-2 text-white transition-colors hover:bg-red-700 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:outline-none"
>
Sign out
</button>
</div>
{/if}
</div>
Using Better Auth from the server
To use Better Auth's server methods in server rendering, server functions, or any other SvelteKit server code, use Convex functions and call the function from your server code.
With withServerConvexToken set up in your hook (see Set up authentication token), the token is automatically available — no need to pass it explicitly:
import type { PageServerLoad } from "./$types";
import { api } from "#convex/_generated/api.js";
import { createConvexHttpClient } from "@mmailaender/convex-better-auth-svelte/sveltekit";
export const load: PageServerLoad = async () => {
const client = createConvexHttpClient();
const currentUser = await client.query(api.auth.getCurrentUser, {});
return { currentUser };
};You can also use convexLoad for SSR with automatic live subscription upgrade.
It needs the ConvexLoadResult transport in your universal hooks:
import { initConvex, encodeConvexLoad, decodeConvexLoad } from 'convex-svelte/sveltekit';
import { PUBLIC_CONVEX_URL } from '$app/env/public';
initConvex(PUBLIC_CONVEX_URL);
export const transport = {
ConvexLoadResult: { encode: encodeConvexLoad, decode: decodeConvexLoad }
}; import { convexLoad } from "convex-svelte/sveltekit";
import { api } from "#convex/_generated/api.js";
export const load = async () => ({
currentUser: await convexLoad(api.auth.getCurrentUser, {})
});Load data that needs auth in +page.server.ts, as above: its result reaches the
browser through the transport, and the live subscription starts once the user
is authenticated. A universal +page.ts load runs again in the browser during
hydration, before auth is set up, so an auth-dependent query briefly returns its
signed-out result there. To use +page.ts anyway, pass the load's fetch to
convexLoad() and add convex-svelte's
convexLoadHydration
handle after your auth handle: the load then reuses the server's result during
hydration.
Authenticated requests
There are two common ways to make authenticated Convex requests from Svelte components.
Option 1: Conditionally run queries with useQuery and auth.isAuthenticated
Use this when your app has a mix of public and members-only content.
You can read the auth state from useAuth and return "skip" for queries that
should only run once the user is authenticated.
import { api } from "#convex/_generated/api.js";
import { useQuery } from "convex-svelte";
import { useAuth } from "@mmailaender/convex-better-auth-svelte/svelte";
const auth = useAuth();
// Only fetch once the user is authenticated
const memberOnlyPosts = useQuery(api.posts.getMemberOnlyPosts, () =>
auth.isAuthenticated ? {} : "skip"
);
// Always fetched, regardless of auth state
const publicPosts = useQuery(api.posts.getPublicPosts, {});Option 2: Gate a whole members-only area
Use this when a section of your app, or the whole app, is members-only. Instead of gating every query, gate the area once:
-
Redirect signed-out users on the server. Do it in
handle, which runs before anyloadfunction. A guard in+layout.server.tsis not enough: layout loads don't rerun on every navigation, and page loads run in parallel (see SvelteKit's implications for authentication).src/hooks.server.ts import { redirect } from "@sveltejs/kit"; import type { Handle } from "@sveltejs/kit/hooks"; import { getToken } from "@mmailaender/convex-better-auth-svelte/sveltekit"; import { withServerConvexToken } from "convex-svelte/sveltekit/server"; export const handle: Handle = async ({ event, resolve }) => { const token = getToken(event.cookies); event.locals.token = token; if (!token && event.route.id?.startsWith("/(app)")) redirect(303, "/sign-in"); return withServerConvexToken(token, () => resolve(event)); }; -
Render the area only once Convex has accepted the token. Components inside the
{#if}block don't exist before that, so none of their queries run without auth. When the user signs out, they unmount and their queries stop.src/routes/(app)/+layout.svelte <script lang="ts"> import { useAuth } from "@mmailaender/convex-better-auth-svelte/svelte"; let { children } = $props(); const auth = useAuth(); </script> {#if auth.isAuthenticated} {@render children()} {:else if auth.isLoading} <p>Loading…</p> {:else} <p>Please sign in.</p> {/if} -
Pass the server's auth state (see SSR). Signed-in users then render the area on the first paint, and their first queries are already authenticated.
These steps shape what users see. They don't protect your data: every Convex
function that returns private data must check the user itself, e.g. with
authComponent.getAuthUser(ctx).
Other guides use Convex's expectAuth client option for this. It only holds
requests until the first sign-in, and leaves signed-out users with a client
that never sends anything, so this guide doesn't use it.
SSR (Optional)
By default, authentication state is determined on the client, which can cause a brief loading state flash while the session is validated. SSR authentication avoids this by providing the auth state from the server during the initial page load.
Benefits
- No loading state flash -
isLoadingstarts asfalse, andisAuthenticatedis immediately correct - Instant content - Protected content renders on first paint
- Better UX - Users see their personalized content without waiting for client-side checks.
Setup
Get auth state in your layout server
Use getAuthState to determine authentication status during SSR. When withServerConvexToken is set up in your hook, call it with no arguments — the token is read automatically:
import type { LayoutServerLoad } from "./$types";
import { getAuthState } from "@mmailaender/convex-better-auth-svelte/sveltekit";
export const load: LayoutServerLoad = () => {
return { authState: getAuthState() }; // synchronous, no await needed
};Pass auth state to the client
Update your layout to pass the server auth state to createSvelteAuthClient.
<script lang="ts">
import { createSvelteAuthClient } from '@mmailaender/convex-better-auth-svelte/svelte';
import { authClient } from '#lib/auth-client.js';
let { children, data } = $props();
// Pass server auth state to prevent loading flash
createSvelteAuthClient({
authClient,
getServerState: () => data.authState
});
</script>
{@render children()}Prefetch user data
For the best experience, also prefetch user data on the server to prevent content flash.
import type { LayoutServerLoad } from "./$types";
import { api } from "#convex/_generated/api.js";
import { createConvexHttpClient, getAuthState } from "@mmailaender/convex-better-auth-svelte/sveltekit";
export const load: LayoutServerLoad = async () => {
const authState = getAuthState();
if (!authState.isAuthenticated) {
return { authState, currentUser: null };
}
const client = createConvexHttpClient();
try {
const currentUser = await client.query(api.auth.getCurrentUser, {});
return { authState, currentUser };
} catch {
return { authState, currentUser: null };
}
};Then use initialData in your queries to prevent data flash:
<script lang="ts">
import { api } from '#convex/_generated/api.js';
import { useQuery } from 'convex-svelte';
import { useAuth } from '@mmailaender/convex-better-auth-svelte/svelte';
let { data } = $props();
const auth = useAuth();
const currentUserResponse = useQuery(
api.auth.getCurrentUser,
() => (auth.isAuthenticated ? {} : 'skip'),
() => ({
initialData: data.currentUser,
keepPreviousData: true
})
);
</script>SvelteKit 2
@mmailaender/convex-better-auth-svelte supports SvelteKit 2 as well. Follow the
guide above with these differences:
-
Environment variables: skip
src/env.tsand import from$env/static/publicinstead of$app/env/public.src/hooks.ts import { initConvex } from 'convex-svelte/sveltekit'; import { PUBLIC_CONVEX_URL } from '$env/static/public'; initConvex(PUBLIC_CONVEX_URL); -
Handletype: import it from@sveltejs/kitinstead of@sveltejs/kit/hooks. -
Import aliases: instead of the
#convexsubpath import, add a$convexalias tosvelte.config.js, and import with$convex/...and$lib/...:svelte.config.js import adapter from '@sveltejs/adapter-auto'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), kit: { adapter: adapter(), alias: { $convex: './src/convex' } } }; export default config;
To upgrade an existing app to SvelteKit 3, run npx sv migrate sveltekit-3, then
declare your Convex variables in src/env.ts as shown in
Set environment variables.