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
✔ 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:
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:
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:
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:
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
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.
Going further
- The classic version in detail
- CRUD with remote functions for the rest of the app
- Pattern page: Authentication with remote functions · Docs: vela enable auth


