---
title: "Moving a SvelteKit app on Cloudflare to SurrealDB Earth"
description: "Connect SvelteKit 3 on Workers to Earth with a per-request SDK client in hooks.server.ts; Earth has root auth only, so keep user sessions in your app."
url: https://surrealdb.com/blog/moving-a-sveltekit-app-on-cloudflare-to-surrealdb-earth
date: 2026-11-14
authors: "Tobie Morgan Hitchcock"
---

# Moving a SvelteKit app on Cloudflare to SurrealDB Earth

![Moving a SvelteKit app on Cloudflare to SurrealDB Earth](https://cdn.surrealdb.com/shd76wiuuru1hdanp1wpiunc.auto)

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-cloudflare` 8.0.2 and Wrangler 4.
- An Earth instance. Create one from the Deploy page in [SurrealDB Studio](https://studio.surrealdb.com), 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.

```jsonc
// 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:

```js
// 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`:

```ts
// 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.

```surql
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](https://surrealdb.com/docs/learn/security/authorization/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](https://surrealdb.com/docs/learn/querying/concepts-and-guides/connecting-from-serverless-and-edge#cloudflare-workers). When the request carries a token cookie, the hook authenticates the client with it:

```ts
// 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:

```ts
// 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:

```ts
// 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:

```ts
// 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 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](https://surrealdb.com/docs/manage/instances/earth#what-is-measured) 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 `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](https://surrealdb.com/docs/manage/instances/earth#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 `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.

## 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](https://github.com/surrealdb/better-auth) instead, SurrealDB publishes an adapter for it.

The [SurrealDB Earth documentation](https://surrealdb.com/docs/manage/instances/earth) covers deploying, connecting and how usage is measured, and the [record access reference](https://surrealdb.com/docs/reference/query-language/statements/define/access/record) covers every clause of `DEFINE ACCESS ... TYPE RECORD`.

[Start free on Earth](https://surrealdb.com/earth)
