Convex + Better Auth
Framework Guides

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-svelte

Customize the convex path

SvelteKit doesn't like referencing code outside of source, so customize the convex functionsDir to be under src/.

convex.json
{
  "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 dev

Add a #convex import alias

Add a subpath import to your package.json, so you can import from src/convex/ as #convex/...:

package.json
{
  "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.

This component requires Convex 1.25.0 or later.
npm install @convex-dev/better-auth @mmailaender/convex-better-auth-svelte
npm install better-auth@~1.6.15

Register the component

Register the Better Auth component in your Convex project.

src/convex/convex.config.ts
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.

src/convex/auth.config.ts
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:5173

Add environment variables to the .env.local file created by npx convex dev. It will be picked up by your framework dev server.

.env.local
# 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:5173

On 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.

src/env.ts
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.

Some Typescript errors will show until you save the file.
src/convex/auth.ts
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.

src/lib/auth-client.ts
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.

src/convex/http.ts
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.

src/routes/api/auth/[...all]/+server.ts
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.

src/hooks.ts
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().

src/routes/+layout.svelte
<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.

src/hooks.server.ts
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.

src/app.d.ts
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

src/routes/+page.svelte
<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:

src/routes/+page.server.ts
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:

src/hooks.ts
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 } 
}; 
src/routes/+page.server.ts
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.

src/routes/+page.svelte
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:

  1. Redirect signed-out users on the server. Do it in handle, which runs before any load function. A guard in +layout.server.ts is 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));
    };
  2. 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}
  3. 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 - isLoading starts as false, and isAuthenticated is 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:

src/routes/+layout.server.ts
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.

src/routes/+layout.svelte
<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.

src/routes/+layout.server.ts
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:

src/routes/+page.svelte
<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.ts and import from $env/static/public instead 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);
  • Handle type: import it from @sveltejs/kit instead of @sveltejs/kit/hooks.

  • Import aliases: instead of the #convex subpath import, add a $convex alias to svelte.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.

On this page