Sveltekit and Pocketbase in server-side rendering
As a young, unemployed, software engineer, I’m more inclined to try to force my way to get people to use the same technologies that like, so here is break down of how to get Sveltekit and Pocketbase(0.24.3) working in a server-side environment. this example is in svelte version 4 and Sveltekit 2 but svelte 5 is backwards compatible.
It should be noted that the development team behind Pocketbase does have this to say “The easiest way to use Pocketbase is by interacting with its Web APIs directly from the client-side (e.g. mobile app or browser SPA).
It was designed with this exact use case in mind, and it is also the reason why there are general purpose JSON APIs for listing, pagination, sorting, filtering, etc.” and for JS SSR they list the following for caution:
Security issues caused by incorrectly initialized and shared JS SDK instance in a long-running server-side context.
OAuth2 integration difficulties related to the server-side only OAuth2 flow (or its mixed “all-in-one” client-side handling and sharing a cookie with the server-side).
Proxying realtime connections and essentially duplicating the same thing PocketBase already does.
Performance bottlenecks caused by the default single-threaded Node.js process and the excessive resources utilization due to the server-side rendering and heavy back-and-forth requests communication between the different layers (client<->Node.js<->PocketBase).
Now I'm not saying my implementation is the best is and if there is any vulnerability please let me know but so far I haven't had any bad instances using adapter node and I haven't been able to produce instances where data between users got mixed up, but this is how I went about implementing it as there is not a lot of resources that exist for this approach, and the little there is convoluted so here is a demo of like a student/teacher app, this example is using superforms form cisco heat(https://superforms.rocks/) and Zod. (This is not a step-by-step tutorial, the comments in the code are there for a reason. another assumption I'm making is that you understand Sveltekit data flow from hooks.server.ts all the way to page.svelte). Now let's start by creating a utils.ts in our lib directory
//utils.ts
export const serializeNonPOJOs = (obj) => {
return structuredClone(obj);
};
In our hooks.server.ts,
//@ts-nocheck
import PocketBase from 'pocketbase';
import { serializeNonPOJOs } from '$lib/utils';
import { redirect } from '@sveltejs/kit';
import { PB_URL } from '$env/static/private';
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
//console.log("PB Server hook started")
event.locals.pb = new PocketBase(PB_URL);
// load the store data from the request cookie string
event.locals.pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '');
//lets shorten the code for the user
if(event.locals.pb.authStore.isValid){
// console.log("PB authStore is valid")
event.locals.user = serializeNonPOJOs(event.locals.pb.authStore.model)
//console.log("event.locals.user",event.locals.user)
} else {
event.locals.user = undefined
}
try {
// get an up-to-date auth store state by verifying and refreshing the loaded auth model (if any)
event.locals.pb.authStore.isValid && await event.locals.pb.collection('users').authRefresh();
//console.log("PB up to date")
} catch (_) {
// clear the auth store on failed refresh
event.locals.pb.authStore.clear();
console.log("PB cleared")
}
const response = await resolve(event);
// send back the default 'pb_auth' cookie to the client with the latest store state
response.headers.append('set-cookie', event.locals.pb.authStore.exportToCookie({ secure: false }));
// console.log("PB instance set in hooks:", event.locals.pb);
//protect the Portal route
if (event.url.pathname === '/Portal') {
const user = await event.locals.user
if (user) {
// User exists
let path: string;
// 2 checks: redirect based on user role to correct dashboard
if (user.full_details === false) {
console.log("user full details is false")
switch (user.role) {
case 'student':
path = '/Portal/StudentDashboard/Profile/edit';
break;
case 'teacher':
path = '/Portal/TeacherDashboard/Profile/edit';
default:
path = '/Portal/Something_went_wrong'; // Fallback path
}
} else {
console.log('user full details is true')
switch (user.role) {
case 'student':
path = '/Portal/StudentDashboard';
break;
case 'teacher':
path = '/Portal/TeacherDashboard';
default:
path = '/Portal/Something_went_wrong'; // Fallback path
}
}
redirect(303, path);
} else {
//user does not exist
console.log("user is not signed in")
redirect(303, '/auth');
}
}
return response;
};
using patmood/pocketbase-typegen: Typescript generation for pocketbase records we can generate types for our Sveltekit project automatically. create a types folder inside lib, then add this script to your package.json “typegen”: “npx pocketbase-typegen — out ./src/lib/types/pocketbase.d.ts — env”. In your env file insert the following (super user account)
PB_TYPEGEN_URL=xxxxxxxx
[email protected]
PB_TYPEGEN_PASSWORD=xxxxxxxxxx
and you can then run “npm run typegen” and your types should be generated.
we also have to update our app.d.ts so our app is aware of our types.
import PocketBase from 'pocketbase';
import { UsersRecord } from '$/lib/types/pocketbase.d.ts';
declare global {
declare namespace App {
interface Locals {
pb: PocketBase;
user?: UsersRecord;
}
}
}
dont forget to pass the user object to +layout.server.ts
//@ts-nocheck
export const load = async ({ locals }) => {
const user = locals.user || undefined;
return {
user
};
};
From here we should be good to be able to use pocketbase across our application. The most important thing to note below is for the form actions were getting pb(pocketbase) from locals. Here is an example of a sign up and sign in +page.server.ts (using ciscoheat superforms (componetized) and Zod).
import { SignInSchema, SignUpSchema } from '$lib/types/schema';
import { redirect } from '@sveltejs/kit'
import { ClientResponseError } from 'pocketbase';
import { fail, error } from '@sveltejs/kit';
import { superValidate, message } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import type { Actions } from './$types';
export const load = async ({ locals: { user } }) => {
// if the user is already logged take them straight to the users page
if (user !== undefined) {
console.log("hey you, lets get inside")
throw redirect(303, '/Portal')
}
// Create and validate sign up and sign in form
const SignIn_Form = await superValidate(zod(SignInSchema));
const SignUp_Form = await superValidate(zod(SignUpSchema));
// Combine data and form into a single object
const data:any = { user, SignUp_Form, SignIn_Form };
return data;
}
export const actions = {
signIn: async ({ request, locals: { pb } }) => {
const signIn_Form = await superValidate(request, zod(SignInSchema));
console.log('Sign In', signIn_Form);
// error checking for the form itself
if(!signIn_Form.valid) {
return fail(400, {message:'Invalid signIn Form Submission',errors: signIn_Form.errors,signIn_Form});
} else {
const { email, password } = signIn_Form.data;
// sending it to PB
try {
await pb.collection('users').authWithPassword(
email,
password,
)
console.log("PB run(signIn)")
} catch (err) {
console.log("PB run ERROR! (signIn)", err)
//if PB returns error
if(err){
if (err instanceof ClientResponseError && err.status === 400) {
console.log("error", error)
return message(signIn_Form,{text: 'Invalid Credentials, Try again.', status: 401});
}
return fail(500, { message: 'Server error. Try again later.'})
}
}
// Successful sign-In, update the store and dispatch custom event.
redirect(303, '/Portal')
return message(signIn_Form, {text: 'Login In...'});
}
},
signUp: async ({ request, locals: { pb } }) => {
console.log("PocketBase instance (pb):", pb);
const signUp_Form = await superValidate(request, zod(SignUpSchema));
console.log('Sign Up', signUp_Form);
// error checking for the form itself
if(!signUp_Form.valid) {
return fail(400, {message:'Invalid Form Submission',errors: signUp_Form.errors,signUp_Form});
} else {
const { email, password, confirm } = signUp_Form.data;
let passwordConfirm = confirm;
// sending it to PB
try {
//default role
let role:string = "student";
await pb.collection('users').create({
email,
password,
passwordConfirm,
role
})
console.log("PB run(signUp)")
await pb.collection('users').requestVerification(email);
} catch (err) {
//if PB returns error
console.log("PB run ERROR(signUp):", JSON.stringify(err));
if(err){
if (err instanceof ClientResponseError && err.status === 400) {
return message(signUp_Form,{text: 'Something went wrong, try again', status: 401});
}
return fail(500, { message: 'Server error. Try again later.'})
}
}
// Successful sign-Up, update the store and dispatch custom event.
return message(signUp_Form,{text: 'Check your Email for Confirmation.'});
}
}
} satisfies Actions
Zod is a validation libray for those who are not familiar with it, superforms has the ability to other libraries as well.
//lib/types/schema.ts
import { z } from 'zod'
// Sign In Schema
export const SignInSchema = z.object({
email: z.string().email().min(3),
password: z.string().min(6)
})
export type SignInSchema = typeof SignInSchema;
// Sign Up Schema
export const SignUpSchema = z.object({
email: z.string().email().min(3),
password: z.string().min(6),
confirm: z.string().min(6)
}).refine((data) => data.password == data.confirm,{
message: "Passwords didn't match",
path: ["confirm"]
})
export type SignUpSchema = typeof SignUpSchema
export const UserProfileSchema = z.object({
id: z.string().min(3),
username: z.string({ message: "Invalid Government ID Number" }).regex(/^\d{8}$/, { message: "Government ID Number must be exactly 8 digits" }), // government ID number
first_name: z.string().min(3, {message: "Invalid First Name"}).max(20),
last_name: z.string().min(3, {message: "Invalid Last Name"}).max(20),
gender: z.string( {message: "Invalid Gender"}).min(1).max(10),
phone: z.string().min(8),
DOB: z.string({ message: "Invalid Date" }).refine((dateStr) => { const parsedDate = new Date(dateStr); return !isNaN(parsedDate.getTime());}, { message: "Invalid Date format" }),
})
export type UserProfileSchema = typeof UserProfileSchema
To log out the user since we are working in a server environment is just as simple as creating a logout route with a +server.ts only and clearing the authStore.
import { redirect } from '@sveltejs/kit';
export const POST = ({ locals }) => {
locals.pb.authStore.clear();
locals.user = undefined;
redirect(303, '/');
}
And calling it like so in your +page.svelte(tailwind css)
Log Out
let's say you want the student to update their profile info, it becomes a similar story to the auth route. here is a +page.server.ts
import { UserProfileSchema } from "$lib/types/schema";
import { ClientResponseError } from 'pocketbase';
import { fail, error } from '@sveltejs/kit';
import { superValidate, message } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import type { Actions } from './$types';
export const load = async ({ locals: { user } }) => {
//create and validate store form
const UserProfile_Form = await superValidate(user, zod(UserProfileSchema)); // we pass the user as well to auto populate
//Combine data from locals and form into a single object
const data:any = { UserProfile_Form};
return data;
}
export const actions = {
//"UserProfile" action here refers to the name of the form action, so what should happen when the form
//is submitted.
UserProfile: async ({ request, locals: { pb } }) => {
const UserProfile_Form = await superValidate(request, zod(UserProfileSchema));
console.log('UserProfile Form from +page.server.ts', UserProfile_Form);
// error checking for the form itself, here we will tell superforms if there is an error in the form
if (!UserProfile_Form.valid) {
console.log("invalid form bruh")
return fail(400, {message:'Invalid UserProfile Form Submission',errors: UserProfile_Form.errors,UserProfile_Form});
} else {
const { id,
username,
first_name,
last_name,
gender,
phone,
DOB,
role,
full_details,
} = UserProfile_Form.data;
// sending it to PB (pocketbase)
console.log("try block");
try {
await pb.collection('users').update(id, {
username,
first_name,
last_name,
gender,
phone,
DOB,
role,
full_details,
});
console.log("PB run (User Profile updated)");
} catch (err) {
//if PB returns error
if(err){
if (err instanceof ClientResponseError && err.status === 400) {
console.log(error)
return message(UserProfile_Form,{text: 'Something went wrong, Try again.', status: 401});
}
return fail(500, { message: 'Server error. Try again later.'})
}
}
// this sends a message to the client informing the user that the form has been submitted.
return message(UserProfile_Form,{text: 'UserProfile updated.', status: 200});
}
}
} satisfies Actions
here is the accompanying +page.svelte(tailwind css)
//@ts-nocheck
import SuperForm from '$lib/components/superforms/Form.svelte';
import TextField from '$lib/components/superforms/TextField.svelte';
import HiddenInputField from '$lib/components/superforms/HiddenInputField.svelte';
import TextAreaField from '$lib/components/superforms/TextAreaField.svelte';
import { goto } from '$app/navigation';
import SelectField from '$lib/components/superforms/SelectField.svelte';
import DateInput from '$lib/components/superforms/DateInput.svelte';
import counties from '$lib/counties.json';
export let data
let { user, UserProfile_Form } = data;
$: ({ UserProfile_Form } = data);
//gender object
const genders = [
{ name: 'Male', value: 'M' },
{ name: 'Female', value: 'F' },
{ name: 'Other', value: 'O' },
];
User Profile
{#if message}
{message.text}
{/if}
{#each genders as gender}
{gender.name}
{/each}
Update
And there you have it. The only problem with this approach I have noticed is you live and die by form actions every time you want to talk to Pocketbase, hence why I'm using Superforms to make my life easier. I still agree with the authors of Pocketbase that this approach does remove a lot of simplicity and adds extra overhead and if your application is not complex, using Sveltekit in SPA mode will make things move a lot faster, but if you're working for a client who needs just a little bit more control and you are working with external APIs where secret keys are involved that should NEVER be able to access them, the extra work has proven fruitful in my case. And yeah, hope this helps someone, and I’ll see you, when I see you.