NEW

SurrealDB 4 is here, introducing SurrealDB Earth and SurrealDS!

Learn more

Moving a SvelteKit app on Cloudflare to SurrealDB Earth

Engineering

Nov 14, 20269 min read

Tobie Morgan Hitchcock

Tobie Morgan Hitchcock

Show all postsMoving a SvelteKit app on Cloudflare to SurrealDB Earth

Our newsletter

Get tutorials, AI agent recipes, webinars, and early product updates in your inbox every two weeks

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.

  • A SvelteKit 3.1.1 app with @sveltejs/adapter-cloudflare 8.0.2 and Wrangler 4.

  • An Earth instance. Create one from the Deploy page in SurrealDB Studio, or with surreal cloud instance deploy.

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.

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() })] });

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.

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, and crypto::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 PERMISSIONS clause allows, and a table defined without one gives record users nothing. Here each user can read and update their own user record, 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, and READONLY stops anyone from changing it later. A note created with someone else as its owner fails the create permission and is not written.

  • $auth. $auth is the signed-in user's record ID, which SurrealDB takes from the session, so a check such as owner = $auth reads no user record for each note it checks. From SurrealDB 4.0, $auth.id also 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 1h makes 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.

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.

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.

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.

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 with live(), need WebSocket. Over HTTP, the SDK throws UnsupportedFeatureError for 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.

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 handle and close it in finally, instead of exporting a module-level client.

  • Read the endpoint through $app/env/private, which takes it from the Worker's variables, instead of from process.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.

  • Create an Earth instance and copy its endpoint from the instance's dashboard in Studio.

  • Run the schema, with the user record access method and the table permissions, on the instance.

  • Pass the adapter to the sveltekit() plugin in vite.config.js.

  • Declare SURREAL_URL in src/env.ts, and set it in .env for the build and in wrangler.jsonc for the Worker.

  • Create the client in handle, authenticate it with the token cookie, and close it in finally.

  • Build the app and test the Worker locally with wrangler dev before you deploy.

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.

Start free on Earth

Related posts

Our newsletter

Get tutorials, AI agent recipes, webinars, and early product updates in your inbox every two weeks