├── static ├── favicon.ico ├── favicon.png └── defaults │ └── default-avatar.jpg ├── postcss.config.js ├── src ├── lib │ ├── types │ │ ├── account.ts │ │ ├── users.d.ts │ │ └── supabase.d.ts │ ├── components │ │ ├── navbar │ │ │ ├── LoggedOut.svelte │ │ │ ├── Navbar.svelte │ │ │ ├── LoggedIn.svelte │ │ │ └── ToggleLightDarkMode.svelte │ │ └── svgs │ │ │ └── Google.svelte │ ├── services │ │ ├── supabase.service.ts │ │ └── image.service.ts │ ├── stores │ │ └── user.svelte.ts │ └── database │ │ └── user.database.ts ├── routes │ ├── +page.svelte │ ├── account │ │ ├── logout │ │ │ ├── +page.svelte │ │ │ └── +page.server.ts │ │ ├── +page.server.ts │ │ ├── auth │ │ │ └── callback │ │ │ │ └── google │ │ │ │ └── +server.ts │ │ ├── +page.svelte │ │ ├── login │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ └── register │ │ │ ├── +page.svelte │ │ │ └── +page.server.ts │ ├── errors │ │ ├── +page.ts │ │ └── +page.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ └── +layout.ts ├── app.html ├── app.css ├── app.d.ts └── hooks.server.ts ├── vite.config.ts ├── example.env ├── tailwind.config.js ├── tsconfig.json ├── svelte.config.js ├── eslint.config.js ├── package.json └── README.md /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilroyjones/sveltekit-supabase-template/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilroyjones/sveltekit-supabase-template/HEAD/static/favicon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /static/defaults/default-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kilroyjones/sveltekit-supabase-template/HEAD/static/defaults/default-avatar.jpg -------------------------------------------------------------------------------- /src/lib/types/account.ts: -------------------------------------------------------------------------------- 1 | export type ErrorRegisterUser = { 2 | username?: string; 3 | email?: string; 4 | password?: string; 5 | other?: string; 6 | }; 7 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | -------------------------------------------------------------------------------- /src/lib/components/navbar/LoggedOut.svelte: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | PRIVATE_SUPABASE_OAUTH_REDIRECT=account/auth/callback 2 | PRIVATE_SUPABASE_PROFILE_IMAGE_BUCKET='profile_images' 3 | PRIVATE_SUPABASE_SERVICE_ROLE_KEY= 4 | PUBLIC_ADDRESS=http://localhost:5173 5 | PUBLIC_SUPABASE_URL= 6 | PUBLIC_SUPABASE_ANON_KEY= 7 | PUBLIC_SUPABASE_STORAGE_ENDPOINT= 8 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Welcome!

5 |

Put some site information here!

6 |
7 |
8 |
9 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{html,js,svelte,ts}'], 4 | 5 | theme: { 6 | extend: {} 7 | }, 8 | plugins: [require('daisyui')], 9 | daisyui: { 10 | themes: ['cupcake', 'synthwave'] 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /src/lib/types/users.d.ts: -------------------------------------------------------------------------------- 1 | export type UserUpdate = { 2 | username?: string; 3 | email?: string; 4 | profile_image?: string; 5 | role?: string; 6 | }; 7 | 8 | export type UserInsert = { 9 | id: string; 10 | username: string; 11 | email: string; 12 | profile_image?: string; 13 | role: string; 14 | }; 15 | -------------------------------------------------------------------------------- /src/routes/account/logout/+page.svelte: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Logged out

5 |

Return home

6 |
7 |
8 |
9 | -------------------------------------------------------------------------------- /src/routes/errors/+page.ts: -------------------------------------------------------------------------------- 1 | import type { PageLoad } from './$types'; 2 | 3 | /** 4 | * This gets the error and passes it on to the page 5 | * 6 | * @param url 7 | * @returns 8 | */ 9 | export const load: PageLoad = ({ url }) => { 10 | const error = url.searchParams.get('error'); 11 | 12 | return { 13 | error 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .std-input-label { 7 | @apply block text-sm font-medium text-left; 8 | } 9 | 10 | .std-input-error { 11 | @apply text-sm text-error; 12 | } 13 | 14 | .std-input-field { 15 | @apply w-full px-4 py-2 border rounded-md; 16 | } 17 | 18 | .std-input-button { 19 | @apply rounded-md btn focus:outline-none btn-primary; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/routes/errors/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 |
13 |
14 |

Sorry, there was an error!

15 |

{data.error}

16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | import type { Session, SupabaseClient, User } from '@supabase/supabase-js'; 2 | 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | interface Locals { 7 | supabase: SupabaseClient; 8 | safeGetSession: () => Promise<{ session: Session | null; user: User | null }>; 9 | session: Session | null; 10 | user: User | null; 11 | } 12 | // interface PageData {} 13 | // interface PageState {} 14 | // interface Platform {} 15 | } 16 | } 17 | 18 | export {}; 19 | -------------------------------------------------------------------------------- /src/lib/services/supabase.service.ts: -------------------------------------------------------------------------------- 1 | import { PRIVATE_SUPABASE_SERVICE_ROLE_KEY } from '$env/static/private'; 2 | import { PUBLIC_SUPABASE_URL } from '$env/static/public'; 3 | import { createClient } from '@supabase/supabase-js'; 4 | 5 | // Uses the service role key, allowing bypass of RLS policies 6 | export const supabaseServerClient = createClient( 7 | PUBLIC_SUPABASE_URL, 8 | PRIVATE_SUPABASE_SERVICE_ROLE_KEY, 9 | { 10 | auth: { 11 | persistSession: false, 12 | autoRefreshToken: false, 13 | detectSessionInUrl: false 14 | } 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutServerLoad } from './$types'; 2 | 3 | /** 4 | * Gets data from our users table. 5 | * 6 | * @param param0 7 | * @returns 8 | */ 9 | export const load: LayoutServerLoad = async ({ locals }) => { 10 | const session = locals.session; 11 | const { data, error } = await locals.supabase.auth.getUser(); 12 | 13 | if (data && error == null) { 14 | const result = await locals.supabase.from('users').select('*').eq('id', data.user.id); 15 | 16 | if (result.data && result.data.length > 0) { 17 | return { 18 | session: locals.session, 19 | user: result.data[0] 20 | }; 21 | } 22 | } 23 | 24 | return { 25 | session 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/routes/account/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | // Libraries and modules 2 | import { redirect } from '@sveltejs/kit'; 3 | 4 | // Types 5 | import type { Actions } from '@sveltejs/kit'; 6 | import type { PageServerLoad } from '../$types'; 7 | 8 | /** 9 | * Server load on login checks if user is already logged in. 10 | */ 11 | export const load: PageServerLoad = async ({ locals }) => { 12 | const session = await locals.safeGetSession(); 13 | if (session.user == null) { 14 | redirect(303, '/'); 15 | } 16 | }; 17 | 18 | /** 19 | * Logs the user out 20 | */ 21 | export const actions = { 22 | logout: async ({ locals }) => { 23 | await locals.supabase.auth.signOut(); 24 | } 25 | } satisfies Actions; 26 | -------------------------------------------------------------------------------- /src/lib/components/navbar/Navbar.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /src/lib/stores/user.svelte.ts: -------------------------------------------------------------------------------- 1 | // Types 2 | import type { Tables } from '$lib/types/supabase'; 3 | 4 | function createUserStore() { 5 | let user: Tables<'users'> | undefined = $state(undefined); 6 | 7 | const set = (updatedUser: Tables<'users'>) => (user = updatedUser); 8 | const reset = () => (user = undefined); 9 | 10 | return { 11 | get exists() { 12 | return user ? true : false; 13 | }, 14 | 15 | get id() { 16 | return user?.id; 17 | }, 18 | 19 | get email() { 20 | return user?.email; 21 | }, 22 | 23 | get username() { 24 | return user?.username; 25 | }, 26 | 27 | get profile_image() { 28 | return user?.profile_image; 29 | }, 30 | 31 | set, 32 | reset 33 | }; 34 | } 35 | 36 | export const userStore = createUserStore(); 37 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import ts from 'typescript-eslint'; 3 | import svelte from 'eslint-plugin-svelte'; 4 | import prettier from 'eslint-config-prettier'; 5 | import globals from 'globals'; 6 | 7 | /** @type {import('eslint').Linter.FlatConfig[]} */ 8 | export default [ 9 | js.configs.recommended, 10 | ...ts.configs.recommended, 11 | ...svelte.configs['flat/recommended'], 12 | prettier, 13 | ...svelte.configs['flat/prettier'], 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.browser, 18 | ...globals.node 19 | } 20 | } 21 | }, 22 | { 23 | files: ['**/*.svelte'], 24 | languageOptions: { 25 | parserOptions: { 26 | parser: ts.parser 27 | } 28 | } 29 | }, 30 | { 31 | ignores: ['build/', '.svelte-kit/', 'dist/'] 32 | } 33 | ]; 34 | -------------------------------------------------------------------------------- /src/lib/components/navbar/LoggedIn.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 31 | -------------------------------------------------------------------------------- /src/routes/account/+page.server.ts: -------------------------------------------------------------------------------- 1 | // Libraries and modules 2 | import { ImageService } from '$lib/services/image.service'; 3 | import { UserDatabase } from '$lib/database/user.database'; 4 | 5 | // Types and constants 6 | import { redirect, type Actions } from '@sveltejs/kit'; 7 | import type { PageServerLoad } from './$types'; 8 | 9 | /** 10 | * Server load on registration checks if user is already logged in and redirects 11 | * to home page, otherwise returns auth providers if they exist. 12 | */ 13 | export const load: PageServerLoad = async ({ locals }) => { 14 | const session = await locals.safeGetSession(); 15 | if (session.user == null) { 16 | redirect(303, '/'); 17 | } 18 | }; 19 | 20 | /** 21 | * Registration action 22 | */ 23 | export const actions = { 24 | /** 25 | * Upload action for profile image 26 | */ 27 | upload: async ({ locals, request }) => { 28 | const formData = await request.formData(); 29 | const file = formData.get('file') as File; 30 | 31 | if (file) { 32 | const profileImage = await ImageService.uploadProfileImage(file); 33 | const { user } = await locals.safeGetSession(); 34 | if (user && profileImage) { 35 | const updatedUser = await UserDatabase.update(user.id, { 36 | profile_image: profileImage 37 | }); 38 | return updatedUser; 39 | } 40 | } 41 | } 42 | } satisfies Actions; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svelte5-supabase-template", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "lint": "prettier --check . && eslint .", 12 | "format": "prettier --write ." 13 | }, 14 | "devDependencies": { 15 | "@sveltejs/adapter-auto": "^3.0.0", 16 | "@sveltejs/kit": "^2.0.0", 17 | "@sveltejs/vite-plugin-svelte": "^3.0.0", 18 | "@types/eslint": "^8.56.7", 19 | "@types/uuid": "^10.0.0", 20 | "autoprefixer": "^10.4.19", 21 | "daisyui": "^4.12.7", 22 | "eslint": "^9.0.0", 23 | "eslint-config-prettier": "^9.1.0", 24 | "eslint-plugin-svelte": "^2.36.0", 25 | "globals": "^15.0.0", 26 | "postcss": "^8.4.38", 27 | "prettier": "^3.1.1", 28 | "prettier-plugin-svelte": "^3.1.2", 29 | "svelte": "^5.0.0-next.1", 30 | "svelte-check": "^3.6.0", 31 | "tailwindcss": "^3.4.4", 32 | "tslib": "^2.4.1", 33 | "typescript": "^5.0.0", 34 | "typescript-eslint": "^8.0.0-alpha.20", 35 | "vite": "^5.0.3" 36 | }, 37 | "type": "module", 38 | "dependencies": { 39 | "@supabase/ssr": "^0.3.0", 40 | "@supabase/supabase-js": "^2.43.5", 41 | "uuid": "^10.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 57 | 58 | {#if mounted} 59 | 60 | {@render children()} 61 | {/if} 62 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | // Libraries 2 | import { createBrowserClient, createServerClient, isBrowser, parse } from '@supabase/ssr'; 3 | 4 | // Types 5 | import type { LayoutLoad } from './$types'; 6 | 7 | // Variables 8 | import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; 9 | 10 | /** 11 | * 12 | * @param param0 13 | * @returns 14 | */ 15 | export const load: LayoutLoad = async ({ data, depends, fetch }) => { 16 | /** 17 | * Declare a dependency so the layout can be invalidated, for example, on 18 | * session refresh. 19 | */ 20 | depends('supabase:auth'); 21 | 22 | const supabase = isBrowser() 23 | ? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { 24 | global: { 25 | fetch 26 | }, 27 | cookies: { 28 | get(key) { 29 | const cookie = parse(document.cookie); 30 | return cookie[key]; 31 | } 32 | } 33 | }) 34 | : createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, { 35 | global: { 36 | fetch 37 | }, 38 | cookies: { 39 | get() { 40 | return JSON.stringify(data.session); 41 | } 42 | } 43 | }); 44 | 45 | /** 46 | * It's fine to use `getSession` here, because on the client, `getSession` is 47 | * safe, and on the server, it reads `session` from the `LayoutData`, which 48 | * safely checked the session using `safeGetSession`. 49 | */ 50 | const { 51 | data: { session } 52 | } = await supabase.auth.getSession(); 53 | 54 | const { user } = data; 55 | 56 | // Don't need this? 57 | // const { 58 | // data: { user } 59 | // } = await supabase.auth.getUser(); 60 | 61 | return { session, supabase, user }; 62 | }; 63 | -------------------------------------------------------------------------------- /src/lib/services/image.service.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Image service 3 | * 4 | * Used for uploading images (png, jpg, tiff, webp), though could be easily modifed to 5 | * allow for uploading any type of file by changing the "getSuffix" function. 6 | * 7 | * Changes given image name into a uuid to ensure no overlapping names. 8 | * 9 | */ 10 | 11 | // Libraries and modules 12 | import { v4 as uuidv4 } from 'uuid'; 13 | import { supabaseServerClient } from './supabase.service'; 14 | 15 | // Variables 16 | import { PRIVATE_SUPABASE_PROFILE_IMAGE_BUCKET } from '$env/static/private'; 17 | 18 | /** 19 | * Given a File type it checks the suffix to ensure it's a valid image type. 20 | * 21 | * @param supabase 22 | * @param userId 23 | * @param profileImageUrl 24 | * @returns 25 | */ 26 | async function uploadProfileImage(file: File): Promise { 27 | const suffix = getImageSuffix(file.type); 28 | if (suffix) { 29 | return await addToBucket(file, suffix); 30 | } 31 | } 32 | 33 | /** 34 | * Uploads the image to the bucket defined by the given env variable. 35 | * 36 | * @param supabase 37 | * @param imageBlob 38 | */ 39 | async function addToBucket(imageBlob: Blob, suffix: string): Promise { 40 | const buffer = await imageBlob.arrayBuffer(); 41 | const imageBuffer = Buffer.from(buffer); 42 | const imageName = uuidv4() + suffix; 43 | const { data, error } = await supabaseServerClient.storage 44 | .from(PRIVATE_SUPABASE_PROFILE_IMAGE_BUCKET) 45 | .upload(imageName, imageBuffer, { 46 | contentType: imageBlob.type, 47 | upsert: true 48 | }); 49 | 50 | if (data) { 51 | return data.path; 52 | } 53 | 54 | console.error('Error uploading the image:', error); 55 | } 56 | 57 | /** 58 | * Checks suffix and returns undefined if not valid; 59 | * 60 | * @param mimeType 61 | * @returns 62 | */ 63 | function getImageSuffix(mimeType: string): string | undefined { 64 | switch (mimeType) { 65 | case 'image/png': 66 | return '.png'; 67 | case 'image/jpeg': 68 | return '.jpg'; 69 | case 'image/tiff': 70 | return '.tiff'; 71 | case 'image/webp': 72 | return '.webp'; 73 | } 74 | } 75 | 76 | export const ImageService = { 77 | uploadProfileImage 78 | }; 79 | -------------------------------------------------------------------------------- /src/routes/account/auth/callback/google/+server.ts: -------------------------------------------------------------------------------- 1 | // Libraries and modules 2 | import { redirect, type RequestHandler } from '@sveltejs/kit'; 3 | import { supabaseServerClient } from '$lib/services/supabase.service'; 4 | import { UserDatabase } from '$lib/database/user.database'; 5 | 6 | // Types 7 | import type { Tables } from '$lib/types/supabase'; 8 | 9 | /** 10 | * Called to complete the user signing with Google 11 | * @param event 12 | */ 13 | export const GET: RequestHandler = async (event) => { 14 | const { 15 | url, 16 | locals: { supabase } 17 | } = event; 18 | 19 | // Retrieves the unique value (code) used in the sign in process as well as 20 | // the redirect route. 21 | const code = url.searchParams.get('code') as string; 22 | const next = url.searchParams.get('next') ?? '/'; 23 | 24 | if (code) { 25 | let user: Tables<'users'> | null; 26 | 27 | const supabaseClient = event.locals.supabase; 28 | const supabaseServer = supabaseServerClient; 29 | 30 | // Checks that the code is valid (PKCE - proof key for exchange) 31 | const codeResult = await supabaseClient.auth.exchangeCodeForSession(code); 32 | if (codeResult.error) { 33 | throw redirect(303, '/auth/auth-code-error'); 34 | } 35 | 36 | // There should be a session created at this point which contains the user 37 | // metadata from Google 38 | const session = await event.locals.safeGetSession(); 39 | if (session.user == null) { 40 | throw redirect(303, '/auth/session-not-found'); 41 | } 42 | 43 | // Check to see if this user has already signed in previously 44 | user = await UserDatabase.getById(session.user.id); 45 | 46 | // If the user doesn't exist then create an entry in our own user tables 47 | // (not the private, inaccessible one supabase uses) 48 | if (user == null) { 49 | if (session.user.id && session.user.email) { 50 | user = await UserDatabase.insert({ 51 | id: session.user.id, 52 | email: session.user.email, 53 | username: session.user.email, 54 | profile_image: 'default-avatar.jpg', 55 | role: 'user' 56 | }); 57 | } 58 | } 59 | 60 | // If user still equals null, then we've had an error 61 | if (user == null) { 62 | throw redirect(303, '/errors?error=Failed to create user'); 63 | } 64 | 65 | // Success, reroute the user to the desired location 66 | throw redirect(303, `/${next.slice(1)}`); 67 | } 68 | 69 | throw redirect(303, '/errors?error=Failure with Google auth server'); 70 | }; 71 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | // Libraries 2 | import { createServerClient } from '@supabase/ssr'; 3 | import { sequence } from '@sveltejs/kit/hooks'; 4 | 5 | // Types 6 | import type { Database } from '$lib/types/supabase'; 7 | import type { Handle } from '@sveltejs/kit'; 8 | 9 | // Variables 10 | import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'; 11 | 12 | /** 13 | * 14 | * @param param0 15 | * @returns 16 | */ 17 | const supabase: Handle = async ({ event, resolve }) => { 18 | event.locals.supabase = createServerClient( 19 | PUBLIC_SUPABASE_URL, 20 | PUBLIC_SUPABASE_ANON_KEY, 21 | { 22 | cookies: { 23 | get: (key) => event.cookies.get(key), 24 | /** 25 | * SvelteKit's cookies API requires `path` to be explicitly set in 26 | * the cookie options. Setting `path` to `/` replicates previous/ 27 | * standard behavior. 28 | */ 29 | set: (key, value, options) => { 30 | event.cookies.set(key, value, { ...options, path: '/' }); 31 | }, 32 | remove: (key, options) => { 33 | event.cookies.delete(key, { ...options, path: '/' }); 34 | } 35 | } 36 | } 37 | ); 38 | 39 | /** 40 | * Unlike `supabase.auth.getSession()`, which returns the session _without_ 41 | * validating the JWT, this function also calls `getUser()` to validate the 42 | * JWT before returning the session. 43 | */ 44 | event.locals.safeGetSession = async () => { 45 | const { 46 | data: { session } 47 | } = await event.locals.supabase.auth.getSession(); 48 | 49 | if (!session) { 50 | return { session: null, user: null }; 51 | } 52 | 53 | const { 54 | data: { user }, 55 | error 56 | } = await event.locals.supabase.auth.getUser(); 57 | if (error) { 58 | // JWT validation has failed 59 | return { session: null, user: null }; 60 | } 61 | 62 | // FIX: Check if updated - needed to remove odd error on getSession 63 | // https://github.com/supabase/auth-js/issues/873 64 | // @ts-ignore 65 | delete session.user; 66 | 67 | return { session: Object.assign({}, session, { user }), user }; 68 | }; 69 | 70 | return resolve(event, { 71 | filterSerializedResponseHeaders(name) { 72 | /** 73 | * Supabase libraries use the `content-range` and `x-supabase-api-version` 74 | * headers, so we need to tell SvelteKit to pass it through. 75 | */ 76 | return name === 'content-range' || name === 'x-supabase-api-version'; 77 | } 78 | }); 79 | }; 80 | 81 | export const handle: Handle = sequence(supabase); 82 | -------------------------------------------------------------------------------- /src/routes/account/+page.svelte: -------------------------------------------------------------------------------- 1 | 43 | 44 |
45 |
46 | {#if userStore.exists} 47 |
48 |
49 | 63 | 72 | 73 |
74 |

{userStore.username}

75 |
76 | {/if} 77 |
78 |
79 | -------------------------------------------------------------------------------- /src/routes/account/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | // Libraries 2 | import { AuthApiError } from '@supabase/supabase-js'; 3 | import { fail, redirect } from '@sveltejs/kit'; 4 | 5 | // Types 6 | import type { Actions } from '@sveltejs/kit'; 7 | import type { PageServerLoad } from '../$types'; 8 | 9 | // Variables 10 | import { PUBLIC_ADDRESS } from '$env/static/public'; 11 | import { PRIVATE_SUPABASE_OAUTH_REDIRECT } from '$env/static/private'; 12 | 13 | /** 14 | * Server load on login checks if user is already logged in. 15 | */ 16 | export const load: PageServerLoad = async ({ locals }) => { 17 | const session = await locals.safeGetSession(); 18 | if (session.user) { 19 | redirect(303, '/'); 20 | } 21 | }; 22 | 23 | /** 24 | * Handles the login actions for email and Google 25 | */ 26 | export const actions: Actions = { 27 | /** 28 | * Registers the user via email. 29 | * 30 | * Note: This is limited with the free Supabase plan. Check how many email 31 | * users are allowed per hour. Last I checked it was three new accounts. 32 | * 33 | * @param request 34 | * @param locals 35 | * @returns 36 | */ 37 | email: async ({ request, locals }) => { 38 | const body = Object.fromEntries(await request.formData()); 39 | const { error } = await locals.supabase.auth.signInWithPassword({ 40 | email: body.email as string, 41 | password: body.password as string 42 | }); 43 | 44 | // If there's an error send that back to the user to be displayed on the 45 | // Login page. 46 | if (error) { 47 | if (error instanceof AuthApiError && error.status === 400) { 48 | return fail(400, { 49 | error: 'Invalid username or password' 50 | }); 51 | } 52 | return fail(500, { 53 | message: 'Server error. Try again later.' 54 | }); 55 | } 56 | 57 | throw redirect(303, '/'); 58 | }, 59 | 60 | /** 61 | * Registers via Google 62 | * 63 | * Note: This should be done with the public Supabase client attached to 64 | * locals in hooks.server.ts. 65 | * 66 | * @param param0 67 | * @returns 68 | */ 69 | google: async ({ locals }) => { 70 | const { data, error } = await locals.supabase.auth.signInWithOAuth({ 71 | provider: 'google', 72 | options: { 73 | scopes: 'https://www.googleapis.com/auth/userinfo.email', 74 | redirectTo: `${PUBLIC_ADDRESS}/${PRIVATE_SUPABASE_OAUTH_REDIRECT}/google` 75 | } 76 | }); 77 | 78 | if (error) { 79 | if (error instanceof AuthApiError && error.status === 400) { 80 | return fail(400, { 81 | error: 'Invalid credentials' 82 | }); 83 | } 84 | return fail(500, { 85 | message: 'Server error. Try again later.' 86 | }); 87 | } 88 | 89 | throw redirect(303, data.url); 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/lib/database/user.database.ts: -------------------------------------------------------------------------------- 1 | // Libraries and modules 2 | import { supabaseServerClient } from '$lib/services/supabase.service'; 3 | 4 | // Types 5 | import type { Tables } from '$lib/types/supabase'; 6 | import type { UserInsert, UserUpdate } from '$lib/types/users'; 7 | 8 | /** 9 | * Gets the user by id 10 | * 11 | * @param id 12 | * @returns Tables<'users'> 13 | */ 14 | async function getById(id: string): Promise | null> { 15 | const { data, error } = await supabaseServerClient.from('users').select('*').eq('id', id); 16 | 17 | if (error == null) { 18 | return data[0]; 19 | } 20 | 21 | console.error('UserDatabase:getById - ', error); 22 | return null; 23 | } 24 | 25 | /** 26 | * Gets user by email 27 | * 28 | * @param id 29 | * @returns Tables<'users'> 30 | */ 31 | async function getByEmail(email: string): Promise | null> { 32 | const { data, error } = await supabaseServerClient.from('users').select('*').eq('email', email); 33 | 34 | if (error == null) { 35 | return data[0]; 36 | } 37 | 38 | console.error('UserDatabase:getByEmail - ', error); 39 | return null; 40 | } 41 | 42 | /** 43 | * Gets user by username 44 | * 45 | * @param id 46 | * @returns Tables<'users'> 47 | */ 48 | async function getByUsername(username: string): Promise | null> { 49 | const { data, error } = await supabaseServerClient 50 | .from('users') 51 | .select('*') 52 | .eq('username', username); 53 | 54 | if (error == null) { 55 | return data[0]; 56 | } 57 | 58 | console.error('UserDatabase:getByUsername - ', error); 59 | return null; 60 | } 61 | 62 | /** 63 | * Inserts a new user 64 | * 65 | * @param userId 66 | * @param userData 67 | * @returns Tables<'users'> 68 | */ 69 | async function insert(userData: UserInsert): Promise | null> { 70 | const { data, error } = await supabaseServerClient.from('users').insert(userData).select(); 71 | 72 | if (error == null) { 73 | return data[0]; 74 | } 75 | 76 | console.error('UserDatabase:insert - ', error); 77 | return null; 78 | } 79 | 80 | /** 81 | * Updates a user given their id and UserDate 82 | 83 | * @param userId 84 | * @param userData 85 | * @returns Tables<'users'> 86 | */ 87 | async function update(userId: string, userData: UserUpdate): Promise | null> { 88 | const { data, error } = await supabaseServerClient 89 | .from('users') 90 | .update(userData) 91 | .eq('id', userId) 92 | .select(); 93 | 94 | if (error == null) { 95 | return data[0]; 96 | } 97 | 98 | console.error('UserDatabase:update - ', error); 99 | return null; 100 | } 101 | 102 | export const UserDatabase = { 103 | getById, 104 | getByEmail, 105 | getByUsername, 106 | insert, 107 | update 108 | }; 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## SvelteKit Supabase template 2 | 3 | This is a work-in-progress template for SvelteKit (with Svelte 5) using Supabase. This is an 4 | [article](https://www.thespatula.io/svelte/sveltekit_supabase/) discussing some of the details of the authentication process. 5 | 6 | It's built using Tailwind and DaisyUI, has light and dark modes and a simple profile page that allows changing one's profile image. 7 | 8 | ### Setup 9 | 10 | #### 1. Clone the repo and install libraries 11 | 12 | ```bash 13 | git clone https://github.com/kilroyjones/sveltekit-supabase-template 14 | cd sveltekit-supabase-template 15 | npm i 16 | ``` 17 | 18 | #### 2. Getting Google OAuth keys 19 | 20 | On the [Google dashboard](https://console.cloud.google.com/apis/dashboard) either select an exisitng project or create a new one. The go to **Credentials** and **Create Credentials** then select **OAuth client id** and go through the process, putting in _http://localhost:5173_ for **URIs1**. For _Authorized redirect URIs_ you can put anything for now, but we'll replace it after the next step. 21 | 22 | #### 3. Supabase project 23 | 24 | In Supabase you can create a new project or modify an existing one. Then go to **Authentication** and **Providers** and **Google** and copy in your Client ID and Secret from the Google Dashboard, after which you can copy the **Callback URL** in the space from step 2. 25 | 26 | This will connect Supabase with Google. 27 | 28 | #### 4. Create users table 29 | 30 | This will be our public users tables: 31 | 32 | ```sql 33 | CREATE TABLE users ( 34 | id uuid DEFAULT uuid_generate_v4() PRIMARY KEY REFERENCES auth.users(id), 35 | email varchar, 36 | username varchar NOT NULL, 37 | profile_image varchar, 38 | role varchar, 39 | created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, 40 | updated_at timestamptz, 41 | FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE 42 | ); 43 | ``` 44 | 45 | #### 5. Set policies for users table 46 | 47 | This allows users to select their own information: 48 | 49 | ```sql 50 | CREATE POLICY "select_own_user" ON users 51 | FOR SELECT USING ( 52 | auth.uid() = id 53 | ); 54 | ``` 55 | 56 | #### 6. Create storage 57 | 58 | Under storage create a new bucket called **profile_images** and then create a policy which allows authenticated users to read the images. 59 | 60 | You should also upload the **default-avatar.jpg** to the bucket. This can be found in the **static/defaults** folder. 61 | 62 | #### 7. Set up the environment variables 63 | 64 | Under the Supabase settings in **API** and **Storage** you'll find the URLs and keys needs to complete the environment variables found in **example.env**. Then you can do the following: 65 | 66 | ```bash 67 | cp example.env .env 68 | ``` 69 | 70 | #### 8. Run the program 71 | 72 | ```bash 73 | npm run dev 74 | ``` 75 | 76 | You should then be able to go to [http://localhost:5173](http://localhost:5173) and run the program as normal. 77 | -------------------------------------------------------------------------------- /src/lib/components/navbar/ToggleLightDarkMode.svelte: -------------------------------------------------------------------------------- 1 | 60 | 61 |
62 | 82 |
83 | 84 | 99 | -------------------------------------------------------------------------------- /src/routes/account/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 54 | 55 |
56 |
57 |

Login

58 | 59 |
60 |
61 | 62 | 70 |
71 | 72 |
73 | 74 | 82 |
83 | 84 |
85 |

{error}

86 |
87 | 88 |
89 | 90 |
91 |
92 | 93 |
94 |
95 |
96 | 97 |
103 | 104 |
105 |
106 |
107 | -------------------------------------------------------------------------------- /src/routes/account/register/+page.svelte: -------------------------------------------------------------------------------- 1 | 49 | 50 |
51 |
52 |

Register

53 | 54 |
60 |
61 | 62 | 69 |

{errors?.username || ''}

70 |
71 | 72 |
73 | 74 | 81 |

{errors?.email || ''}

82 |
83 | 84 |
85 | 86 | 93 |

{''}

94 |
95 | 96 |
97 | 98 | 105 |

{errors?.password || ''}

106 |
107 | 108 |
109 |

{errors?.other || ''}

110 |
111 | 112 |
113 | 114 |
115 |
116 | 117 |
118 |
119 |
120 | 121 |
127 | 128 | 129 |
130 |
131 | -------------------------------------------------------------------------------- /src/routes/account/register/+page.server.ts: -------------------------------------------------------------------------------- 1 | // Libraries 2 | import { AuthApiError } from '@supabase/supabase-js'; 3 | import { fail, redirect } from '@sveltejs/kit'; 4 | 5 | // Types and constants 6 | import type { Actions } from '@sveltejs/kit'; 7 | import type { ErrorRegisterUser } from '$lib/types/account'; 8 | import type { PageServerLoad } from './$types'; 9 | import type { Tables } from '$lib/types/supabase'; 10 | 11 | // Variables 12 | import { UserDatabase } from '$lib/database/user.database'; 13 | import { PRIVATE_SUPABASE_OAUTH_REDIRECT } from '$env/static/private'; 14 | import { PUBLIC_ADDRESS } from '$env/static/public'; 15 | 16 | /** 17 | * Server load on registration checks if user is already logged in and redirects 18 | * to home page, otherwise returns auth providers if they exist. 19 | */ 20 | export const load: PageServerLoad = async ({ locals }) => { 21 | const session = await locals.safeGetSession(); 22 | if (session.user) { 23 | redirect(303, '/'); 24 | } 25 | }; 26 | 27 | /** 28 | * Registration action 29 | */ 30 | export const actions = { 31 | /** 32 | * Email registration 33 | */ 34 | email: async ({ locals, request }) => { 35 | // Using the service role client 36 | const supabase = locals.supabase; 37 | 38 | // Get form data 39 | const formData = await request.formData(); 40 | const username = formData.get('username') as string; 41 | const email = formData.get('email') as string; 42 | const password = formData.get('password') as string; 43 | const passwordConfirm = formData.get('passwordConfirm') as string; 44 | 45 | // Check if the email or username has been used. 46 | const userEmail = await UserDatabase.getByEmail(email); 47 | const userUsername = await UserDatabase.getByUsername(username); 48 | 49 | let errors: ErrorRegisterUser = {}; 50 | 51 | if (userEmail) { 52 | errors.email = 'Email already in use.'; 53 | } else if (userUsername) { 54 | errors.username = 'Username already in use.'; 55 | } else if (password != passwordConfirm) { 56 | errors.password = 'Passwords do not match'; 57 | } 58 | 59 | // Check the errors objects to see if any exist 60 | if (Object.keys(errors).length > 0) { 61 | return fail(500, { errors: errors }); 62 | } 63 | 64 | let user: Tables<'users'> | null = null; 65 | 66 | const { data, error: error } = await supabase.auth.signUp({ email, password }); 67 | 68 | // Insert user if all the necessary data has been inserted during sign up. 69 | if (data && data.user && data.user.email && data.user.user_metadata.email) { 70 | user = await UserDatabase.insert({ 71 | id: data.user.id, 72 | email: data.user.email, 73 | username: data.user.email, 74 | profile_image: 'default-avatar.jpg', 75 | role: 'user' 76 | }); 77 | } 78 | 79 | if (error || user == null) { 80 | // If there was an error or we couldn't create the additional 'users' data 81 | // then rollback the user creation. 82 | if (data && data.user) { 83 | await supabase.auth.admin.deleteUser(data.user.id); 84 | } 85 | return redirect(303, '/errors'); 86 | } else { 87 | throw redirect(302, '/account/login'); 88 | } 89 | }, 90 | 91 | /** 92 | * Google OAuth2 93 | * 94 | * We get the provider from list of possible providers (set in Pocketbase) by 95 | * name and then set the cookie. If valid, we concat Google's auth url with 96 | * the redirect set in Google console as well as our provider name see 97 | * /account/oauth/google route. 98 | * 99 | * @param param0 100 | */ 101 | google: async ({ locals }) => { 102 | const { data, error: err } = await locals.supabase.auth.signInWithOAuth({ 103 | provider: 'google', 104 | options: { 105 | scopes: 'https://www.googleapis.com/auth/userinfo.email', 106 | redirectTo: `${PUBLIC_ADDRESS}/${PRIVATE_SUPABASE_OAUTH_REDIRECT}/google` 107 | } 108 | }); 109 | 110 | if (err) { 111 | if (err instanceof AuthApiError && err.status === 400) { 112 | return fail(400, { 113 | error: 'Invalid credentials' 114 | }); 115 | } 116 | return fail(500, { 117 | message: 'Server error. Try again later.' 118 | }); 119 | } 120 | 121 | throw redirect(303, data.url); 122 | } 123 | } satisfies Actions; 124 | -------------------------------------------------------------------------------- /src/lib/components/svgs/Google.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 43 | 44 | 151 | -------------------------------------------------------------------------------- /src/lib/types/supabase.d.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[] 8 | 9 | export type Database = { 10 | public: { 11 | Tables: { 12 | users: { 13 | Row: { 14 | created_at: string | null 15 | email: string 16 | id: string 17 | profile_image: string 18 | role: string 19 | updated_at: string | null 20 | username: string 21 | } 22 | Insert: { 23 | created_at?: string | null 24 | email: string 25 | id: string 26 | profile_image: string 27 | role: string 28 | updated_at?: string | null 29 | username: string 30 | } 31 | Update: { 32 | created_at?: string | null 33 | email?: string 34 | id?: string 35 | profile_image?: string 36 | role?: string 37 | updated_at?: string | null 38 | username?: string 39 | } 40 | Relationships: [ 41 | { 42 | foreignKeyName: "users_id_fkey" 43 | columns: ["id"] 44 | isOneToOne: true 45 | referencedRelation: "users" 46 | referencedColumns: ["id"] 47 | }, 48 | ] 49 | } 50 | } 51 | Views: { 52 | [_ in never]: never 53 | } 54 | Functions: { 55 | [_ in never]: never 56 | } 57 | Enums: { 58 | [_ in never]: never 59 | } 60 | CompositeTypes: { 61 | [_ in never]: never 62 | } 63 | } 64 | } 65 | 66 | type PublicSchema = Database[Extract] 67 | 68 | export type Tables< 69 | PublicTableNameOrOptions extends 70 | | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) 71 | | { schema: keyof Database }, 72 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 73 | ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 74 | Database[PublicTableNameOrOptions["schema"]]["Views"]) 75 | : never = never, 76 | > = PublicTableNameOrOptions extends { schema: keyof Database } 77 | ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 78 | Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { 79 | Row: infer R 80 | } 81 | ? R 82 | : never 83 | : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & 84 | PublicSchema["Views"]) 85 | ? (PublicSchema["Tables"] & 86 | PublicSchema["Views"])[PublicTableNameOrOptions] extends { 87 | Row: infer R 88 | } 89 | ? R 90 | : never 91 | : never 92 | 93 | export type TablesInsert< 94 | PublicTableNameOrOptions extends 95 | | keyof PublicSchema["Tables"] 96 | | { schema: keyof Database }, 97 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 98 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 99 | : never = never, 100 | > = PublicTableNameOrOptions extends { schema: keyof Database } 101 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 102 | Insert: infer I 103 | } 104 | ? I 105 | : never 106 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 107 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 108 | Insert: infer I 109 | } 110 | ? I 111 | : never 112 | : never 113 | 114 | export type TablesUpdate< 115 | PublicTableNameOrOptions extends 116 | | keyof PublicSchema["Tables"] 117 | | { schema: keyof Database }, 118 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 119 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 120 | : never = never, 121 | > = PublicTableNameOrOptions extends { schema: keyof Database } 122 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 123 | Update: infer U 124 | } 125 | ? U 126 | : never 127 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 128 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 129 | Update: infer U 130 | } 131 | ? U 132 | : never 133 | : never 134 | 135 | export type Enums< 136 | PublicEnumNameOrOptions extends 137 | | keyof PublicSchema["Enums"] 138 | | { schema: keyof Database }, 139 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } 140 | ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] 141 | : never = never, 142 | > = PublicEnumNameOrOptions extends { schema: keyof Database } 143 | ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] 144 | : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] 145 | ? PublicSchema["Enums"][PublicEnumNameOrOptions] 146 | : never 147 | --------------------------------------------------------------------------------