Every app with users starts the same way: a login page, a signup page, “forgot your password”, a verification email, somewhere to change your details, and a rule that keeps signed-out visitors away from the rest. Written by hand that is a week of forms, cookies and edge cases before the app does anything of its own. vela enable auth writes the whole set on top of PocketBase, as ordinary SvelteKit routes you can read and change.
Prerequisites
A vela project with a backend: npx vela create my-app, or npx vela bless inside an existing SvelteKit project (details). Verification and reset emails need an SMTP server in production; vela enable smtp configures one.
Run the command
✔ Created 49 files, modified 4 ✔ Added collection oauth_accounts ✔ Installed 16 UI components src/routes/(public)/(auth)/login/ src/routes/(public)/(auth)/signup/ src/routes/(public)/(auth)/reset/ src/routes/(public)/(auth)/confirm-reset/[token]/ src/routes/(public)/(auth)/otp/[token]/ src/routes/(public)/(auth)/confirm-verification/[token]/ src/routes/(public)/(auth)/confirm-email-change/[token]/ src/routes/(public)/(auth)/logout/ src/routes/(app)/dashboard/ src/routes/(app)/settings/ src/lib/schemas/ src/hooks.server.ts
Each route comes with its page, its server code, a Zod schema for its form and a server.test.ts. Twenty-one tests in total.
What was generated
A protected area
One line in the server hook makes every route under (app) require a session. A signed-out visitor is sent to /login with a redirect parameter, and lands back where they were after signing in:
import { env } from "$env/dynamic/private";
import { handlePocketbase } from "@velastack/pocketbase";
export const handle = handlePocketbase({
pocketbaseUrl: env.POCKETBASE_URL,
superuserEmail: env.POCKETBASE_SUPERUSER_EMAIL,
superuserPassword: env.POCKETBASE_SUPERUSER_PASSWORD,
auth: { protectedRoutes: ["/(app)"] },
});
The (app) group gets a layout with a sidebar, breadcrumbs and a user menu, a /dashboard to land on and a /settings page. Its server layout hands the signed-in user to every page beneath it:
export const load = ({ locals }) => {
const user = locals.pb.authStore.record!;
const breadcrumbs = [{ title: "Home", url: "/dashboard" }];
return { user, breadcrumbs };
};
The root layout exposes the same record as data.user, so a public page can show “Sign in” or the user’s name.
Login
The login page asks PocketBase which sign-in methods are switched on and adapts. With one-time codes enabled the form defaults to emailing a code; otherwise it asks for a password; with only OAuth providers it shows just their buttons:
import { fail, superValidate, message } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { loginSchema } from "$lib/schemas/login";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
export const load = async ({ locals }) => {
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
const authMethods = await locals.admin.collection("users").listAuthMethods();
const type: "password" | "otp" | "oauth2" = authMethods.otp.enabled
? "otp"
: authMethods.password.enabled
? "password"
: "oauth2";
return {
form: await superValidate(
zod4(loginSchema.default({ type, email: "", password: "" })),
),
authMethods,
};
};
export const actions = {
default: async ({ locals, request, cookies, url }) => {
const form = await superValidate(request, zod4(loginSchema));
if (!form.valid) {
return fail(400, { form });
}
const redirectParam = url.searchParams.get("redirect");
if (form.data.type === "otp") {
const req = await locals.pb
.collection("users")
.requestOTP(form.data.email);
return redirect(
303,
`/otp/${req.otpId}${redirectParam ? `?redirect=${encodeURIComponent(redirectParam)}` : ""}`,
);
} else if (form.data.type === "password") {
try {
await locals.pb
.collection("users")
.authWithPassword(form.data.email, form.data.password);
} catch (error: any) {
return message(
form,
{ type: "error", text: error.response.message },
{ status: 400 },
);
}
}
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);
},
};
The action handles both paths. A one-time code request redirects to /otp/<id> to enter the code; a password sets the session cookie and redirects:
import { fail, superValidate, message } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { loginSchema } from "$lib/schemas/login";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";
export const load = async ({ locals }) => {
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
const authMethods = await locals.admin.collection("users").listAuthMethods();
const type: "password" | "otp" | "oauth2" = authMethods.otp.enabled
? "otp"
: authMethods.password.enabled
? "password"
: "oauth2";
return {
form: await superValidate(
zod4(loginSchema.default({ type, email: "", password: "" })),
),
authMethods,
};
};
export const actions = {
default: async ({ locals, request, cookies, url }) => {
const form = await superValidate(request, zod4(loginSchema));
if (!form.valid) {
return fail(400, { form });
}
const redirectParam = url.searchParams.get("redirect");
if (form.data.type === "otp") {
const req = await locals.pb
.collection("users")
.requestOTP(form.data.email);
return redirect(
303,
`/otp/${req.otpId}${redirectParam ? `?redirect=${encodeURIComponent(redirectParam)}` : ""}`,
);
} else if (form.data.type === "password") {
try {
await locals.pb
.collection("users")
.authWithPassword(form.data.email, form.data.password);
} catch (error: any) {
return message(
form,
{ type: "error", text: error.response.message },
{ status: 400 },
);
}
}
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);
},
};
OAuth buttons are rendered for whatever providers you enable in the PocketBase admin. The click opens the provider’s window and lands on the dashboard; the oauth_accounts collection keeps the provider tokens:
<script lang="ts">
import favicon from "$lib/assets/favicon.svg";
import { site } from "$lib/site";
import { untrack } from "svelte";
import { Button } from "$lib/components/ui/button";
import * as Card from "$lib/components/ui/card";
import { superForm } from "sveltekit-superforms";
import { zod4Client } from "sveltekit-superforms/adapters";
import { loginSchema } from "$lib/schemas/login";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import PocketBase from "pocketbase-sveltekit";
import { goto } from "$app/navigation";
import { page } from "$app/state";
let { data } = $props();
let authMethods = $derived(data.authMethods);
const redirect = page.url.searchParams.get("redirect");
const hasOAuth2 = $derived(
authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0,
);
const hasAuthMethods = $derived(
authMethods.password.enabled || authMethods.otp.enabled || hasOAuth2,
);
const form = superForm(
untrack(() => data.form),
{
validators: zod4Client(loginSchema),
},
);
const handleOAuth2 = (provider: string) => {
const pb = new PocketBase("/");
pb.collection("users")
.authWithOAuth2({ provider, createData: {} })
.then(() => goto("/dashboard"));
};
const { form: formData, message } = form;
</script>
<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-6">
<a href="/" class="flex items-center gap-2 self-center font-medium">
<div
class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
>
<img src={favicon} alt="logo" class="size-4" />
</div>
{site.name}
</a>
<div class="flex flex-col gap-6">
<Card.Root>
{#if hasAuthMethods}
<Card.Header class="text-center">
<Card.Title class="text-xl">Welcome back</Card.Title>
{#if hasOAuth2}
<Card.Description>Choose a login method</Card.Description>
{:else}
<Card.Description>Use your email to login</Card.Description>
{/if}
</Card.Header>
{:else}
<Card.Header class="text-center">
<Card.Title class="text-xl">Login is disabled</Card.Title>
<Card.Description
>Check back later for login options</Card.Description
>
</Card.Header>
{/if}
<Card.Content>
<form method="POST">
<div class="grid gap-6">
{#if hasOAuth2}
<div class="flex flex-col gap-4">
{#each authMethods.oauth2.providers as provider}
<Button
variant="outline"
class="w-full"
onclick={() => handleOAuth2(provider.name)}
>
<img
src="/admin/_/images/oauth2/{provider.name}.svg"
class="size-5 bg-white p-0.5 rounded-sm"
alt=""
/>
Login with {provider.displayName}
</Button>
{/each}
</div>
{/if}
{#if authMethods.password.enabled || authMethods.otp.enabled}
{#if hasOAuth2}
<div
class="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
>
<span
class="bg-card text-muted-foreground relative z-10 px-2"
>
Or continue with
</span>
</div>
{/if}
<div class="grid gap-2">
<Form.Field {form} name="email" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Email</Form.Label>
<Input
{...props}
type="email"
bind:value={$formData.email}
required
autocomplete="username"
autofocus
/>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
{#if $formData.type === "password" && authMethods.password.enabled}
<Form.Field {form} name="password" class="col-span-1">
<Form.Control>
{#snippet children({ props })}
<div class="grid grid-cols-[1fr_auto] gap-2">
<Form.Label>Password</Form.Label>
<Input
{...props}
type="password"
bind:value={$formData.password}
required
autocomplete="current-password"
class="col-span-2"
/>
<a
href="/reset"
class="col-start-2 row-start-1 text-sm underline-offset-4 hover:underline"
>Forgot your password?</a
>
</div>
{/snippet}
</Form.Control>
<Form.FieldErrors class="contents text-destructive" />
</Form.Field>
{#if $message && $message.type === "error"}
<div class="text-destructive text-sm font-medium -mt-2">
{$message.text}
</div>
{/if}
<Button type="submit" class="w-full">Login</Button>
{#if authMethods.otp.enabled}
<Button
variant="outline"
class="w-full"
onclick={() => ($formData.type = "otp" as "password")}
>Use one-time code instead</Button
>
{/if}
{:else if $formData.type === "otp" && authMethods.otp.enabled}
<Button type="submit" class="w-full"
>Send one-time code</Button
>
{#if authMethods.password.enabled}
<Button
variant="outline"
class="w-full"
onclick={() => ($formData.type = "password" as "otp")}
>Continue with password</Button
>
{/if}
{/if}
</div>
<input type="hidden" name="type" bind:value={$formData.type} />
{/if}
<div class="text-center text-sm">
Don't have an account?
<a
href="/signup{redirect
? `?redirect=${encodeURIComponent(redirect)}`
: ''}"
class="underline underline-offset-4">Sign up</a
>
</div>
</div>
</form>
</Card.Content>
</Card.Root>
<div
class="text-muted-foreground *:[a]:hover:text-primary *:[a]:underline *:[a]:underline-offset-4 text-balance text-center text-xs"
>
By clicking continue, you agree to our <a href="/terms"
>Terms of Service</a
>
and <a href="/privacy">Privacy Policy</a>.
</div>
</div>
</div>
</div>
Signup
Signup creates the user, sends the verification email, signs them in and sets the cookie. A PocketBase validation error (an email already taken, say) comes back as a field error on the form:
import { redirect } from "@sveltejs/kit";
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setFlash } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";
import { dev } from "$app/environment";
import { signupSchema } from "$lib/schemas/signup";
export const load = async ({ locals }) => {
const authMethods = await locals.admin.collection("users").listAuthMethods();
if (locals.pb.authStore.isValid) {
redirect(303, "/dashboard");
}
return { form: await superValidate(zod4(signupSchema)), authMethods };
};
export const actions = {
default: async ({ locals, request, cookies, url }) => {
const form = await superValidate(request, zod4(signupSchema));
if (!form.valid) {
return fail(400, { form });
}
let user;
try {
user = await locals.admin.collection("users").create({
email: form.data.email,
password: form.data.password,
passwordConfirm: form.data.passwordConfirm,
});
} catch (error) {
setPocketbaseErrors(form, error);
return fail(400, { form });
}
await locals.pb.collection("users").requestVerification(user.email);
await locals.pb
.collection("users")
.authWithPassword(form.data.email, form.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,
);
return redirect(303, redirectUrl);
},
};
Email flows
Reset, verification and email change each have a page that requests the email and a [token] route the link in the email points at. Verification is a plain endpoint that confirms the token and redirects with a toast either way:
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";
export const GET = async ({ params, locals, cookies }) => {
let message = "";
let redirectPath = "";
try {
await locals.admin.collection("users").confirmVerification(params.token);
message = "Email verified successfully.";
redirectPath = "/dashboard";
} catch (error: any) {
message =
"Invalid or expired verification token. Please request a new verification email.";
redirectPath = "/login";
}
setFlash({ type: "toast", message }, cookies);
redirect(303, redirectPath);
};
Settings
/settings has four actions: update the profile (name, avatar, whether the email is visible), change the email, change the password, and resend the verification email:
import { superValidate, fail, withFiles } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
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 load = async ({ parent }) => {
const { user } = await parent();
const profileForm = await superValidate(
{
avatar: user.avatar,
name: user.name,
emailVisibility: user.emailVisibility,
},
zod4(profileSchema),
);
const emailForm = await superValidate(zod4(changeEmailSchema));
const passwordForm = await superValidate(zod4(changePasswordSchema));
return { profileForm, emailForm, passwordForm, user };
};
export const actions = {
updateProfile: async ({ locals, request, cookies }) => {
const form = await superValidate(request, zod4(profileSchema));
if (!form.valid) {
return fail(400, { profileForm: form });
}
await locals.pb
.collection("users")
.update(locals.pb.authStore.record!.id, form.data);
setFlash(
{ type: "toast", message: "Profile updated successfully." },
cookies,
);
return withFiles({ profileForm: form });
},
changeEmail: async ({ locals, request, cookies }) => {
const form = await superValidate(request, zod4(changeEmailSchema));
if (!form.valid) {
return fail(400, { emailForm: form });
}
await locals.pb.collection("users").requestEmailChange(form.data.email);
setFlash(
{
type: "toast",
message: "We sent a confirmation link to your new email.",
},
cookies,
);
return { emailForm: form };
},
changePassword: async ({ locals, request, cookies }) => {
const form = await superValidate(request, zod4(changePasswordSchema));
if (!form.valid) {
return fail(400, { passwordForm: form });
}
await locals.admin
.collection("users")
.update(locals.pb.authStore.record!.id, {
password: form.data.password,
passwordConfirm: form.data.passwordConfirm,
});
setFlash(
{ type: "toast", message: "Password updated successfully." },
cookies,
);
return { passwordForm: form };
},
resendVerificationEmail: async ({ locals, request, cookies }) => {
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 {};
},
};
Tests
Every route ships a server test. The login one signs in with a password, checks the redirect, and checks the message on a wrong password:
import { describe, it, expect } from "vitest";
import * as devalue from "devalue";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";
describe("GET /login", () => {
it("should render the login page", async (context) => {
const response = await context.agent.get("/login" satisfies Match<RouteId>);
expect(response.status).toBe(200);
});
it("should redirect to the dashboard if the user is already authenticated", async (context) => {
await context.agent.authenticateUser();
const response = await context.agent.get("/login" satisfies Match<RouteId>);
expect(response.status).toBe(303);
expect(response.headers.location).toBe("/dashboard");
});
});
describe("POST /login", () => {
it("should login with password", async (context) => {
const response = await context.agent
.post("/login" satisfies Match<RouteId>)
.type("form")
.send({
type: "password",
email: context.user.email,
password: "password",
});
expect(response.body.status).toBe(303);
expect(response.body.location).toBe("/dashboard");
});
it("should error if the password is incorrect", async (context) => {
const response = await context.agent
.post("/login" satisfies Match<RouteId>)
.type("form")
.send({
type: "password",
email: context.user.email,
password: "incorrect",
});
const data = devalue.parse(response.body.data);
expect(response.body.status).toBe(400);
expect(data.form.message.text).toBe("Failed to authenticate.");
});
});
Try it
Open localhost:5173/signup, create an account and you land on /dashboard. Open /settings from the avatar menu, change your name, sign out, sign back in at /login. Visit /dashboard while signed out and watch the redirect.
Use the user in your own code
Inside any load or action, locals.pb is a PocketBase client authenticated as the current user, so collection rules apply to every query. locals.admin bypasses them:
export const load = async ({ locals }) => {
const user = locals.pb.authStore.record!;
const projects = await locals.pb.collection("projects").getFullList();
return { user, projects };
};New routes belong under (app) if they need a session. The generators know this: with auth enabled, vela generate scaffold puts its routes there, and an owner:current_user field fills in from the session and restricts rows to their owner.
users collection in the PocketBase admin. The login page picks it up on the next request and offers "Send one-time code" first, with password as the fallback.Going further
- The same flows on remote functions with
vela enable auth --remote - Add teams and invites once users can sign in
- Give users API keys for scripts and integrations
- Pattern page: Authentication · Docs: vela enable auth


