A SvelteKit app on Cloudflare Workers reaches SurrealDB Earth by opening one surrealdb SDK client for each request in hooks.server.ts and closing it when the request ends. End users sign up and sign in through a record access method that SurrealDB runs itself, so each request carries the user's own token and the Worker holds no database password. This guide covers SvelteKit 3.1.1, @sveltejs/adapter-cloudflare 8.0.2, the JavaScript SDK surrealdb 2.2.2, and SurrealDB 4 on Earth, which launched on 3 November 2026.
What you need
A SvelteKit 3.1.1 app with
@sveltejs/adapter-cloudflare8.0.2 and Wrangler 4.An Earth instance. Create one from the Deploy page in SurrealDB Studio, or with
surreal cloud instance deploy.
What does Earth run on?
Earth runs SurrealDB on the Cloudflare network, with two Cloudflare products underneath it:
Durable Objects. A Durable Object is a small program with a fixed identity that runs one request at a time and keeps its own storage. Earth uses Durable Objects for the parts of an instance that must keep one strict order, such as the commit log.
R2. R2 is Cloudflare's object storage, similar to Amazon S3. Earth keeps the data of an instance in R2, in one location.
For an app, this means that queries arrive through the Cloudflare location nearest to each client, and the data stays in one place. You choose where that place can be when you create the instance. The data residency setting keeps the data in the European Union, in the United States in FedRAMP-compliant data centres, or anywhere in the world. Within it, you can fix a preferred location, such as Western Europe, or let Earth place the data near the instance's first requests. Residency cannot be changed after the instance is created, so an app that must keep European users' data in the EU chooses European Union from the start. An instance that receives no requests for about 60 seconds scales to zero, so the first request after an idle period takes longer than the requests after it.
How do you configure the Cloudflare adapter?
The adapter can build for Cloudflare Workers or for Cloudflare Pages. Workers run your code on the Cloudflare network and can serve static files alongside it. Pages is Cloudflare's older product for static sites with server functions. This guide uses Workers, which the adapter selects when the Wrangler configuration has a main or an assets key. A pages_build_output_dir key selects Pages instead, and the connection code below works the same way in both.
// wrangler.jsonc
{
"name": "notes",
"main": ".svelte-kit/cloudflare/_worker.js",
"compatibility_date": "2026-11-01",
"compatibility_flags": ["nodejs_compat"],
"assets": { "binding": "ASSETS", "directory": ".svelte-kit/cloudflare" },
"vars": {
"SURREAL_URL": "https://<instance-id>.surrealdb.earth"
}
}SvelteKit 3 takes its configuration through the sveltekit() Vite plugin, and refuses a svelte.config.js file with the error config_file_unsupported. Pass the adapter to the plugin:
// vite.config.js
import adapter from '@sveltejs/adapter-cloudflare';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({ plugins: [sveltekit({ adapter: adapter() })] });Which address does the app connect to?
The dashboard of an Earth instance in Studio shows its public endpoint, in the form wss://<instance-id>.surrealdb.earth/rpc. For HTTP, use the same host with https://. The SDK adds /rpc to a remote URL that does not already end with it, so https://<instance-id>.surrealdb.earth is all the app needs.
SvelteKit 3 reads environment variables that you declare in src/env.ts with defineEnvVars, and server code imports them from $app/env/private:
// src/env.ts
import { defineEnvVars } from '@sveltejs/kit/env';
export const variables = defineEnvVars({
SURREAL_URL: {
schema: (value: string | undefined) => {
if (!value) throw new Error('SURREAL_URL is not set');
return value;
}
}
});SvelteKit checks these variables during the build as well as at run time, so the build fails with env_invalid when SURREAL_URL is not set. Put the URL in a .env file for the build. On Cloudflare, the Worker reads it from the vars in wrangler.jsonc, and locally from a .dev.vars file.
The endpoint is the only setting the Worker needs. It holds no database credentials, because every query runs as the signed-in user.
How do end users sign up and sign in?
A record access method defines how end users sign up and sign in, and each signed-in user receives a token for their own user record. Run this schema in Studio's query view on the instance, in the namespace app and the database app. Earth creates the namespace and the database on the first request that uses them.
DEFINE TABLE user SCHEMAFULL
PERMISSIONS FOR select, update WHERE id = $auth;
DEFINE FIELD email ON user TYPE string ASSERT string::is_email($value);
DEFINE FIELD password ON user TYPE string PERMISSIONS FOR select NONE;
DEFINE INDEX email ON user FIELDS email UNIQUE;
DEFINE ACCESS user ON DATABASE TYPE RECORD
SIGNUP (CREATE user SET email = $email, password = crypto::argon2::generate($password))
SIGNIN (SELECT * FROM user WHERE email = $email AND crypto::argon2::compare(password, $password))
DURATION FOR TOKEN 1h, FOR SESSION 1h;
DEFINE TABLE note SCHEMAFULL
PERMISSIONS FOR select, create, update, delete WHERE owner = $auth;
DEFINE FIELD owner ON note TYPE record<user> DEFAULT $auth READONLY;
DEFINE FIELD text ON note TYPE string;The schema works in this way:
Passwords.
crypto::argon2::generate()stores a hash of the password, andcrypto::argon2::compare()checks a password against it. Argon2 is slow and uses a lot of memory on purpose, so that an attacker who gets a copy of the hashes needs a long time to guess each password. A sign-up or a sign-in therefore takes noticeably longer than an ordinary query. That cost comes once, at sign-in: every request after it sends the token, and SurrealDB checks the token's signature without hashing anything.Permissions. A record user can only do what a table's
PERMISSIONSclause allows, and a table defined without one gives record users nothing. Here each user can read and update their ownuserrecord, cannot read the password hash, and can reach only the notes they own.Ownership. A new note takes the signed-in user as its
owner, andREADONLYstops anyone from changing it later. A note created with someone else as its owner fails thecreatepermission and is not written.$auth.$authis the signed-in user's record ID, which SurrealDB takes from the session, so a check such asowner = $authreads nouserrecord for each note it checks. From SurrealDB 4.0,$auth.idalso gives the ID from the session without reading the record, even where the user cannot select their own record, and after the record is deleted. Reading any other field through$auth, such as$auth.email, fetches the user record.Token lifetime.
DURATION FOR TOKEN 1hmakes each token valid for one hour. After that, the user signs in again.
The app needs the crypto::argon2 functions, so keep them allowed in the instance's capabilities. All functions are allowed by default.
How do you connect from hooks.server.ts?
Create the client inside the handle hook, attach it to event.locals, and close it in a finally block. A Worker cannot use a connection that another request opened, so the client belongs to one request, as in the Cloudflare Workers example in the docs. When the request carries a token cookie, the hook authenticates the client with it:
// src/hooks.server.ts
import { SURREAL_URL } from '$app/env/private';
import { Surreal } from 'surrealdb';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const db = new Surreal();
await db.connect(SURREAL_URL, { namespace: 'app', database: 'app', requestTimeout: 5_000 });
event.locals.db = db;
event.locals.signedIn = false;
const token = event.cookies.get('token');
if (token) {
try {
await db.authenticate(token);
event.locals.signedIn = true;
} catch {
// The token has expired or is not valid
event.cookies.delete('token', { path: '/' });
}
}
try {
return await resolve(event);
} finally {
await db.close();
}
};Declare the two values on App.Locals so that load functions and actions see their types:
// src/app.d.ts
import type { Surreal } from 'surrealdb';
declare global {
namespace App {
interface Locals {
db: Surreal;
signedIn: boolean;
}
}
}
export {};A client without a token is a guest. A guest can call signup and signin, and a query from a guest fails with Anonymous access not allowed unless the instance allows guest access.
How do the sign-up and sign-in forms work?
Both forms post to one page with two named actions. signup and signin return the user's token, which the action stores in a cookie that client-side code cannot read:
// src/routes/login/+page.server.ts
import { fail, redirect, type Cookies } from '@sveltejs/kit';
const access = { namespace: 'app', database: 'app', access: 'user' };
function keepToken(cookies: Cookies, token: string) {
cookies.set('token', token, {
path: '/', httpOnly: true, secure: true, sameSite: 'lax', maxAge: 3600
});
}
function credentials(form: FormData) {
return { email: String(form.get('email')), password: String(form.get('password')) };
}
export const actions = {
signup: async ({ request, locals, cookies }) => {
try {
const tokens = await locals.db.signup({ ...access, variables: credentials(await request.formData()) });
keepToken(cookies, tokens.access);
} catch {
return fail(400, { error: 'This email address cannot be used' });
}
redirect(303, '/');
},
signin: async ({ request, locals, cookies }) => {
try {
const tokens = await locals.db.signin({ ...access, variables: credentials(await request.formData()) });
keepToken(cookies, tokens.access);
} catch {
return fail(401, { error: 'Invalid email or password' });
}
redirect(303, '/');
}
};A wrong password, or an email address that has no account, makes signin throw, and the form shows the error. A sign-up with an email address that already has an account fails on the unique index. The cookie's maxAge of one hour matches the token's lifetime.
To sign a user out, delete the cookie. The token itself stays valid until it expires, which is one reason to keep its lifetime short.
How does a page read the user's data?
A load function and its form actions query through locals.db, which runs every query as the signed-in user. The query needs no WHERE owner = ... filter, because the note table's permission clause, PERMISSIONS FOR select, create, update, delete WHERE owner = $auth, returns only the user's own notes:
// src/routes/+page.server.ts
import { redirect } from '@sveltejs/kit';
export const load = async ({ locals }) => {
if (!locals.signedIn) redirect(303, '/login');
const [notes] = await locals.db
.query<[{ text: string }[]]>('SELECT text FROM note ORDER BY text')
.collect();
return { notes };
};
export const actions = {
add: async ({ request, locals }) => {
const form = await request.formData();
await locals.db.query('CREATE note SET text = $text', { text: String(form.get('text')) }).collect();
}
};Two users who sign in see only their own notes, from the same code and the same query.
Should you use HTTP or WebSocket?
Use HTTP for load functions and actions that send one or two queries. Each call is a stateless request, which matches how a Worker handles one request at a time.
Use WebSocket for work that needs state on the connection, or for a request that sends many queries:
Interactive transactions with
db.beginTransaction(), and live queries withlive(), need WebSocket. Over HTTP, the SDK throwsUnsupportedFeatureErrorfor them.On Earth, each HTTP request is routed and authenticated on its own, and costs two request units more than the same request over WebSocket. A request that sends many queries is faster and costs less over one WebSocket connection, opened and closed within the request.
To keep the number of requests down over HTTP, db.transaction([...]) sends several queries as one atomic request, and one SurrealQL query can hold several statements.
What differs from a long-running Node server?
A Node server can open one client at startup and reuse it for every request. A Worker opens a client inside each request and closes it before it returns.
Create the client inside
handleand close it infinally, instead of exporting a module-level client.Read the endpoint through
$app/env/private, which takes it from the Worker's variables, instead of fromprocess.env.Retry a request that Earth answers with HTTP status
503. Earth sends it when the instance is busy or adding compute, the request has not started, and it is safe to send again after a short delay. See Handle busy responses.Define your tables before the app first reads them. A query on a table that does not exist returns an error.
Migration checklist
Create an Earth instance and copy its endpoint from the instance's dashboard in Studio.
Run the schema, with the
userrecord access method and the table permissions, on the instance.Pass the adapter to the
sveltekit()plugin invite.config.js.Declare
SURREAL_URLinsrc/env.ts, and set it in.envfor the build and inwrangler.jsoncfor the Worker.Create the client in
handle, authenticate it with the token cookie, and close it infinally.Build the app and test the Worker locally with
wrangler devbefore you deploy.
Summary
A SvelteKit app on Workers needs one client per request and one record access method in the database. The access method and the table permissions decide who can read and write what, so the app's code stays free of ownership checks and the Worker keeps no database password. To use Better Auth instead, SurrealDB publishes an adapter for it.
The SurrealDB Earth documentation covers deploying, connecting and how usage is measured, and the record access reference covers every clause of DEFINE ACCESS ... TYPE RECORD.