# 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://docs.velastack.dev/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.

## Run the command

```sh
$ vela enable auth --remote
✔ Created 56 files, modified 4
✔ Added collection oauth_accounts
✔ Installed 14 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/(app)/settings/form.remote.ts
  svelte.config.js
```

`svelte.config.js` gains 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 becomes an `error(400)` rather than a form 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/environment";
import { loginSchema } from "$lib/schemas/login";

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

  const redirectParam = url.searchParams.get("redirect");
  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 and thrown:

**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/environment";
import { signupSchema } from "$lib/schemas/signup";

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

  let user: { email: string };

  try {
    user = await locals.admin.collection("users").create({
      email: data.email,
      password: data.password,
      passwordConfirm: data.passwordConfirm,
    });
  } catch (err: any) {
    const response = err?.response ?? {};
    const fieldError = response?.data
      ? Object.values(response.data)[0]
      : undefined;
    const message: string =
      (fieldError as any)?.message ??
      response?.message ??
      "Failed to create account.";
    return { message };
  }

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

  const redirectUrl = url.searchParams.get("redirect") ?? "/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 { setFlash } from "sveltekit-flash-message/server";
import { profileSchema } from "$lib/schemas/profile";
import { changeEmailSchema } from "$lib/schemas/changeEmail";
import { changePasswordSchema } from "$lib/schemas/changePassword";

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();
  await locals.pb
    .collection("users")
    .requestVerification(locals.pb.authStore.record!.email);
  setFlash(
    { type: "toast", message: "We sent a verification email to your email." },
    cookies,
  );
  return { success: true };
});
```

### The config flags

**svelte.config.js**

```js
import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: vitePreprocess(),
  kit: {
    adapter,
    // [!code highlight:3]
    experimental: {
      remoteFunctions: true,
    },
  },
  // [!code highlight:5]
  compilerOptions: {
    experimental: {
      async: true,
    },
  },
};

export default config;
```

## 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](https://docs.velastack.dev/enable/auth)
