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.

2 min read authloginremote-functionspocketbasesveltekit

How to add login and signup to a SvelteKit app covers what the auth pattern gives you. This is the same pattern with one flag: the forms use SvelteKit’s 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

$ 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
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
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
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
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
import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: vitePreprocess(),
  kit: {
    adapter,
    experimental: {
      remoteFunctions: true,
    },
  },
  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 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

Related tutorials

How to add login and signup to a SvelteKit app

Login, signup, password reset, email verification, one-time codes and OAuth buttons, plus a settings page and a protected dashboard. vela enable auth generates all of it on PocketBase, with a server test for every route.

4 min read

CRUD with SvelteKit remote functions - a scaffold without form actions

The same list, create, view and edit pages as the classic scaffold, but the forms post to typed remote functions instead of form actions. vela generate scaffold --remote writes the routes, the .remote.ts handlers, the schema, the collection and the tests.

3 min read

How to give users API keys for a SvelteKit app

Open your app's data as a REST API and let users create and revoke their own keys. vela enable api serves PocketBase's API under /api; vela enable api-keys adds the keys page, hashed secrets and Bearer-token access, with collection rules still in charge.

3 min read