# How to add login with SvelteKit remote functions

The same login, signup, reset and settings flows as vela enable auth, with every form posting to a typed remote function instead of a form action. One flag, and each handler shrinks to a single function.

Pattern: [Enable authentication (remote functions)](https://velastack.dev/patterns/enable-auth-remote) · Docs: https://velastack.dev/docs/enable/auth-remote

[How to add login and signup to a SvelteKit app](https://velastack.dev/tutorials/sveltekit-login-and-signup) covers what the auth pattern gives you. This is the same pattern with one flag: the forms use SvelteKit's [remote functions](https://svelte.dev/docs/kit/remote-functions) instead of form actions and Superforms. Same pages, same cookies, same tests. What changes is the shape of each handler, and if you are building the rest of the app on remote functions, this keeps auth in the same style.

## Prerequisites

A vela project with a backend, same as the classic version, and vela 0.15.13 or later: earlier releases ignore `--remote` and generate the form-action version.

## Run the command

```sh
$ vela enable auth --remote
✔ Created 65 files, modified 6
✔ Added collection oauth_accounts
✔ Installed 13 UI components
  src/routes/(public)/(auth)/login/form.remote.ts
  src/routes/(public)/(auth)/signup/form.remote.ts
  src/routes/(public)/(auth)/reset/form.remote.ts
  src/routes/(public)/(auth)/otp/[token]/form.remote.ts
  src/routes/(public)/(auth)/welcome/form.remote.ts
  src/routes/(app)/settings/form.remote.ts
  vite.config.ts
```

The `sveltekit()` options in `vite.config.ts` gain the `experimental.remoteFunctions` and `compilerOptions.experimental.async` flags if they were not already set.

## What changed

### Login

The action is now a `form()` in `form.remote.ts`. The schema is its first argument, so the handler only runs with valid, typed data, and `getRequestEvent()` supplies `locals`, `cookies` and `url`. A wrong password comes back as a `{ message }` result, which the page shows from `loginForm.result.message`:

**src/routes/(public)/(auth)/login/form.remote.ts**

```ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/env";
import { loginSchema } from "#lib/schemas/login.js";

export const loginForm = form(loginSchema, async (data) => {
  const { locals, cookies, url } = getRequestEvent();

  // Follow only a same-site path (`/x`, not `//host` or `/\host`):
  // `redirect()` throws on an external URL.
  const next = url.searchParams.get("redirect");
  const redirectParam = next && /^\/(?![/\\])/.test(next) ? next : null;
  if (data.type === "otp") {
    const req = await locals.pb.collection("users").requestOTP(data.email);
    redirect(
      303,
      `/otp/${req.otpId}${redirectParam ? `?redirect=${encodeURIComponent(redirectParam)}` : ""}`,
    );
  } else if (data.type === "password") {
    try {
      await locals.pb
        .collection("users")
        .authWithPassword(data.email, data.password ?? "");
    } catch (err: any) {
      return {
        message: (err.response?.message as string) ?? "Failed to authenticate.",
      };
    }
  }

  const redirectUrl = redirectParam ?? "/dashboard";
  const cookie = locals.pb.authStore.getCookie();
  cookies.set("pb_auth", cookie, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure: !dev,
    maxAge: 60 * 60 * 24 * 30,
  });

  redirect(303, redirectUrl);
});
```

`+page.server.ts` keeps only the `load`, which still asks PocketBase for the enabled sign-in methods:

**src/routes/(public)/(auth)/login/+page.server.ts**

```ts
import { redirect } from "@sveltejs/kit";

export const load = async ({ locals }) => {
  if (locals.pb.authStore.isValid) {
    redirect(303, "/dashboard");
  }

  const authMethods = await locals.admin.collection("users").listAuthMethods();

  return { authMethods };
};
```

### Signup

Remote functions have no `setPocketbaseErrors`, so a validation error from PocketBase is unwrapped by hand into the same `{ message }` result:

**src/routes/(public)/(auth)/signup/form.remote.ts**

```ts
import { form, getRequestEvent } from "$app/server";
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { dev } from "$app/env";
import { signupSchema } from "#lib/schemas/signup.js";

/** The first field error, or the response message, of a PocketBase error. */
function errorMessage(err: any, fallback: string): string {
  const response = err?.response ?? {};
  const fieldError = response?.data
    ? Object.values(response.data)[0]
    : undefined;
  return (fieldError as any)?.message ?? response?.message ?? fallback;
}

export const signupForm = form(signupSchema, async (data) => {
  const { locals, cookies, url } = getRequestEvent();

  // Follow only a same-site path (`/x`, not `//host` or `/\host`):
  // `redirect()` throws on an external URL.
  const next = url.searchParams.get("redirect");
  const redirectParam = next && /^\/(?![/\\])/.test(next) ? next : null;
  const redirectQuery = redirectParam
    ? `?redirect=${encodeURIComponent(redirectParam)}`
    : "";

  const { email } = data;

  if (data.type === "otp") {
    // PocketBase only sends codes to existing accounts, so create it first,
    // with a password nobody knows (a reset sets a real one). Checking the
    // code marks the email verified.
    const password = crypto.randomUUID();
    try {
      await locals.admin.collection("users").create({
        email,
        password,
        passwordConfirm: password,
      });
    } catch (err: any) {
      // An existing account gets a code too, the same as logging in with one.
      if (err?.response?.data?.email?.code !== "validation_not_unique") {
        return { message: errorMessage(err, "Failed to create account.") };
      }
    }

    let otpId: string;
    try {
      ({ otpId } = await locals.pb.collection("users").requestOTP(email));
    } catch (err: any) {
      return { message: errorMessage(err, "Failed to send the code.") };
    }
    // A new account has no name yet: ask for one once the code checks out.
    const welcome = `/welcome${redirectQuery}`;
    redirect(303, `/otp/${otpId}?redirect=${encodeURIComponent(welcome)}`);
  }

  try {
    await locals.admin.collection("users").create({
      email,
      password: data.password,
      passwordConfirm: data.passwordConfirm,
    });
  } catch (err: any) {
    return { message: errorMessage(err, "Failed to create account.") };
  }

  await locals.pb.collection("users").requestVerification(email);
  await locals.pb
    .collection("users")
    .authWithPassword(email, data.password ?? "");

  const redirectUrl = redirectParam ?? "/dashboard";
  const cookie = locals.pb.authStore.getCookie();
  cookies.set("pb_auth", cookie, {
    path: "/",
    httpOnly: true,
    sameSite: "lax",
    secure: !dev,
    maxAge: 60 * 60 * 24 * 30,
  });

  setFlash(
    { type: "toast", message: "We sent a confirmation link to your email." },
    cookies,
  );
  redirect(303, redirectUrl);
});
```

### Settings

The four settings actions become four exported functions in one file, each with its schema:

**src/routes/(app)/settings/form.remote.ts**

```ts
import { form, getRequestEvent } from "$app/server";
import { error } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
import { profileSchema } from "#lib/schemas/profile.js";
import { changeEmailSchema } from "#lib/schemas/changeEmail.js";
import { changePasswordSchema } from "#lib/schemas/changePassword.js";

export const updateProfileForm = form(profileSchema, async (data) => {
  const { locals, cookies } = getRequestEvent();

  await locals.pb.collection("users").update(locals.pb.authStore.record!.id, {
    name: data.name,
    avatar: data.avatar,
    emailVisibility: data.emailVisibility,
  });

  setFlash(
    { type: "toast", message: "Profile updated successfully." },
    cookies,
  );
  return { success: true };
});

export const changeEmailForm = form(changeEmailSchema, async (data) => {
  const { locals, cookies } = getRequestEvent();

  await locals.pb.collection("users").requestEmailChange(data.email);
  setFlash(
    {
      type: "toast",
      message: "We sent a confirmation link to your new email.",
    },
    cookies,
  );
  return { success: true };
});

export const changePasswordForm = form(changePasswordSchema, async (data) => {
  const { locals, cookies } = getRequestEvent();

  await locals.admin
    .collection("users")
    .update(locals.pb.authStore.record!.id, {
      password: data.password,
      passwordConfirm: data.passwordConfirm,
    });

  setFlash(
    { type: "toast", message: "Password updated successfully." },
    cookies,
  );
  return { success: true };
});

export const resendVerificationForm = form(async () => {
  const { locals, cookies } = getRequestEvent();
  // Accounts made with a phone number may have no email to verify.
  const email = locals.pb.authStore.record?.email;
  if (!email) {
    error(400, "Your account has no email.");
  }

  await locals.pb.collection("users").requestVerification(email);
  setFlash(
    { type: "toast", message: "We sent a verification email to your email." },
    cookies,
  );
  return { success: true };
});
```

### The config flags

**vite.config.ts**

```ts
import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import adapter from "@sveltejs/adapter-node";

export default defineConfig({
  plugins: [
    tailwindcss(),
    sveltekit({
      compilerOptions: {
        runes: ({ filename }) =>
          filename.split(/[/\\]/).includes("node_modules") ? undefined : true,
        // [!code highlight:3]
        experimental: {
          async: true,
        },
      },
      adapter: adapter(),
      // The public origin, which SvelteKit checks form posts against and uses
      // for prerendered pages. `vela deploy` sets VELA_ORIGIN, and the value is
      // baked in at build time. It is left unset for a deploy that serves more
      // than one host, where each request's own origin is used instead.
      ...(process.env.VELA_ORIGIN
        ? { paths: { origin: process.env.VELA_ORIGIN } }
        : {}),
      // [!code highlight:3]
      experimental: {
        remoteFunctions: true,
      },
    }),
  ],
});
```

## What stayed the same

The protected `(app)` group, the dashboard layout and sidebar, the session cookie, the OAuth buttons, the email flows and the `oauth_accounts` collection are identical. So are the Zod schemas under `src/lib/schemas/`, which is why the two variants can validate the same way on the client.

> **Note:**
> [Form actions vs remote functions](https://velastack.dev/tutorials/sveltekit-form-actions-vs-remote-functions) goes through the trade-offs. The short version: actions are stable and what every generator does by default; remote functions are less ceremony per form and still experimental. Both can coexist in one app.

## Going further

- [The classic version](https://velastack.dev/tutorials/sveltekit-login-and-signup) in detail
- [CRUD with remote functions](https://velastack.dev/tutorials/sveltekit-crud-remote-functions) for the rest of the app
- [Pattern page: Authentication with remote functions](https://velastack.dev/patterns/enable-auth-remote) · [Docs: vela enable auth --remote](https://velastack.dev/docs/enable/auth-remote)
