logo

- 12 min read

How to setup a SaaS project with SvelteKit, Authjs and Stripe

Build the foundation of a SaaS project using Sveltekit, Auth.js, and Stripe, focusing on essential aspects such as basic user authentication and authorization.

Perquisites

Before starting, ensure you have Node.js and npm installed, along with accounts on GitHub and Stripe. You’ll also need a database setup, either with a provider (e.g., MongoDB Atlas) or a local database. Additionally, a basic understanding of JavaScript and SvelteKit is essential to follow along.

Project Setup

Lets begin by setting up a fresh SvelteKit project

npm create svelte@latest demo-project

For the configuration of the project, feel free to choose what you are comfortable with, for simplicity I am going with the Sveltekit Skeleton Project option as it gives us a clean slate to work with. After you’ve made your choice go ahead and install all of the needed dependencies

cd demo-project
npm install

Tailwind + DaisyUI setup

For styles and some styled components we rely on Tailwind and DaisyUI

npx svelte-add@latest tailwindcss
npm install daisyui --save-dev

After the installation modify the tailwind.config.js and add daisyui to the plugins array. tailwind.config.js

module.exports = {
  plugins: [require("daisyui")],
};

Auth.js Setup

Now that we have our blank SvelteKit project created, we continue to setup Auth.js, for it we need to install the @auth/sveltekit package.

npm install @auth/sveltekit --save-dev

.env varibales

At this stage the only environment variable needed is AUTH_SECRET,a secure secret can be generated by using one of the following commands

npx auth secret

or the openssl command, which should be available on all Linux / Mac OS X systems.

openssl rand -base64 33

then add it to the .env file

AUTH_SECRET=secret

Auth.js Configuration file

This is where we control the behavior of the library and can extend it by adding custom logic or adapters.

src/auth.ts

import { SvelteKitAuth } from "@auth/sveltekit";

export const { handle, signIn, signOut } = SvelteKitAuth({
  providers: [],
});

Then we re-export our handle method in SvelteKit’s src/hooks.server.ts file to make it available in the app.

src/hooks.server.ts

export { handle } from "./auth";

This completes the basic setup for Auth.js, but for it to actually be able to authenticate users it needs a provider. For simplicity I’ve chosen to go along with the Github provider, but you can switch that to any of the supported Auth.js providers.

Github Provider

First step is to create a Github OAuth app and get the keys needed for our .env if you are unsure on how to set one up you can follow this blog post.

It’s important that the homepage URL is set to point to http://localhost:5173 and also the Authorization callback URL to point to http://localhost:5173/auth/callback/github

Once the app is created go ahead and modify the .env with your newly generated keys

AUTH_SECRET=secret
AUTH_GITHUB_ID=github_app_id
AUTH_GITHUB_SECRET=github_app_secret

The last step is to import the package in out auth.ts file and include it in the providers array, the final auth.ts looks like this:

import { SvelteKitAuth } from "@auth/sveltekit";
import GitHub from "@auth/sveltekit/providers/github";

export const { handle, signIn, signOut } = SvelteKitAuth({
  providers: [GitHub()],
});

Setting up our pages and layouts

Here we will create our layout, routes, and finish our tailwind setup.

Create a /src/app.css file in our project and add the following snippet

@tailwind base;
@tailwind components;
@tailwind utilities;

Then create the src/routes/+layout.svelte and load the styles in it. While here, lets also add a navbar and some links inside it.

<script>
	import { signOut } from '@auth/sveltekit/client';
	import '../app.css';
</script>

<div class="navbar bg-base-100">
	<div class="max-w-3xl flex w-full m-auto px-2">
		<div class="flex-1">
			<a href="/" class="btn btn-ghost text-xl">LOGO</a>
		</div>
		<div class="flex-none">
			<a href="/admin" class="btn btn-ghost">Protected Route</a>
			<a href="/auth/signin" class="btn btn-ghost">Login</a>
		</div>
	</div>
</div>
<main class="max-w-screen-2xl w-full m-auto px-2">
	<slot></slot>
</main>

That handle function which we earlier exported in the hooks.server.ts adds an auth() method onto our event.locals, which is available in any +layout.server.ts or +page.server.ts file. Therefore, we can access the session in our load function like this.

src/routes/+layout.server.ts

import type { LayoutServerLoad } from "./$types";

export const load: LayoutServerLoad = async (event) => {
  const session = await event.locals.auth();
  return {
    session,
  };
};

For the pages themselves we keep the markup as simple as possible as that is not the main focus of this series.

src/routes/+page.svelte

<div class="hero min-h-[50vh] bg-base-200">
	<div class="hero-content text-center">
		<div>
			<h1>This is the homepage</h1>
		</div>
	</div>
</div>

src/routes/admin/+page.svelte

<script>
	import { page } from '$app/stores';
</script>

<div class="hero min-h-[50vh] bg-base-200">
	<div class="hero-content text-center">
		<h1>
			{$page.data?.session?.user?.email || 'Not logged in'}
		</h1>
	</div>
</div>

Verify it all worked

If we’ve setup everything correctly, we should be able to run our project and access the /, /admin,/auth/signup routes, and also we should be able to finally log in!

npm run dev

Protecting the admin routes

In SvelteKit there are a few ways you could protect routes from unauthenticated users.

The simplest case is protecting a single page, in which case you can put the logic in the +page.server.ts file.

But in this tutorial I’ve chosen to take the more general approach by restricting certain URIs as it is very easy to modify and we avoid a lot of code duplication.

we need to modify two things:

1. Modify hooks.ts

To achieve this we use multiple handles in our hooks.server.ts, for that we leverage SvelteKit’s sequence to execute all of them in series, which allows us to use each function as a middleware, receiving the request and returning a handle which gets passed to the next function.

In the example below we protect all routes beginning with the /admin

import { redirect, type Handle } from "@sveltejs/kit";
import { handle as authenticationHandle } from "./auth";
import { sequence } from "@sveltejs/kit/hooks";

async function authorizationHandle({ event, resolve }) {
  if (event.url.pathname.startsWith("/admin")) {
    const session = await event.locals.auth();
    if (!session) {
      throw redirect(303, "/auth/signin");
    }
  }
  return resolve(event);
}
export const handle: Handle = sequence(authenticationHandle, authorizationHandle);

2. Add a +layout.server.ts to our /src/routes/admin folder

The routes in the /admin directory are protected by the route guard in hooks.server.ts.

To ensure that hooks.server.ts runs for every nested path, we put a +layout.server.ts file in the /admin directory. This file can be empty, but must exist to protect routes that don’t have their own +layout|page.server.ts.

Now if we try to access the Protected Route from our navbar we should get redirected to the login screen!

As a last small addition we can go ahead and add a log out button to our navbar in the /src/routes/+layout.svelte.

<script>
    ...
	import { signOut } from '@auth/sveltekit/client';
	import { page } from '$app/stores';
</script>

<div class="navbar bg-base-100">
	...
	<div class="flex-none">
		<a href="/admin" class="btn btn-ghost">Dashboard</a>
		{#if $page.data?.session?.user}
			<button class="btn btn-ghost" on:click={() => signOut()}>Sign Out</button>
		{:else}
			<a href="/auth/signin" class="btn btn-ghost"> login</a>
		{/if}
	</div>
</div>
...

What is a database adapter

Database Adapters are the bridge we use to connect Auth.js to our database. By default it saves sessions in a cookie so databases are optional. However, if you want to persist user information you would need a database adapter.

Setting up a database adapter

Now is a good time to connect our app to a database, my personal choice is MongoDB. Auth.js provides a quite extensive list of database adapters we can use so feel free to use your favorite database from the list of official adapters

Perquisites

If you decide to go with Mongo I suggest using the free M0 Clusters on MongoDB Atlas as its the fastest way to get it running.

Installing the mongodb and its adapter

	npm install @auth/mongodb-adapter mongodb

Set the environmental variable

	MONGODB_URI="your_mongo_uri"

Create the mongo client

Out of the box adapters don’t handle connections to the DB, so we have to setup a client to do that.

Create the src/lib/db.ts file and paste the following inside it


// This approach is taken from https://github.com/vercel/next.js/tree/canary/examples/with-mongodb

import { MongoClient, ServerApiVersion, ObjectId } from 'mongodb';
import { MONGODB_URI } from '$env/static/private';
if (!MONGODB_URI) {
	throw new Error('Invalid/Missing environment variable: "MONGODB_URI"');
}

const uri = MONGODB_URI;
const options = {
	serverApi: {
		version: ServerApiVersion.v1,
		strict: true,
		deprecationErrors: true
	}
};

let client;
let clientPromise: Promise<MongoClient>;

if (process.env.NODE_ENV === 'development') {
	const globalWithMongo = global as typeof globalThis & {
		_mongoClientPromise?: Promise<MongoClient>;
	};

	if (!globalWithMongo._mongoClientPromise) {
		client = new MongoClient(uri, options);
		globalWithMongo._mongoClientPromise = client.connect();
	}
	clientPromise = globalWithMongo._mongoClientPromise;
} else {
	client = new MongoClient(uri, options);
	clientPromise = client.connect();
}

async function getUserById(id: string) {
	const client = await clientPromise;
	const db = client.db();
	const users = db.collection('users');
	const user = await users.findOne({ _id: new ObjectId(id) });
	return user;
}

export { clientPromise, getUserById };

Connect our mongo client with auth.js

Now that we have the client setup we can make the connection with auth.js by adding adapter: MongoDBAdapter(clientPromise) our auth.ts file

auth.ts after the modification

import { SvelteKitAuth } from "@auth/sveltekit";
import GitHub from "@auth/sveltekit/providers/github";
import { MongoDBAdapter } from "@auth/mongodb-adapter";
import { clientPromise } from "$lib/db";

export const { handle, signIn, signOut } = SvelteKitAuth({
  providers: [GitHub()],
  adapter: MongoDBAdapter(clientPromise),
});

if you now run the app in your dev environment and log in you should be able to see accounts, sessions and users collections are created in your DB.

Setting up subscriptions and webhooks with Stripe

Before we continue, make sure you have a Stripe account set up. If you have an account, get your API key from the Stripe Dashboard then add them to the .env file

STRIPE_API_KEY=""

Configuring Webhook in Stripe Dashboard

To start receiving webhook events, you need to configure a webhook endpoint in the Stripe Dashboard.

  1. Go to the Stripe Dashboard and navigate to the “Developers” section.
  2. Click on “Webhooks” in the left sidebar.
  3. Click on the “Add endpoint” button.
  4. Enter your webhook URL (e.g., https://website.com/api/webhook/stripe ) and select the events you want to receive.(The ones we need for our implementation are checkout.session.completed and customer.subscription.deleted)
  5. Click on “Add endpoint” to save the configuration.
  6. Make sure to replace website.com with your actual domain.

After you create the webhook you need to copy the webhook secret key from webhook’s page and add it to the .env file

STRIPE_WEBHOOK_SECRET=""

Installing stripe dependencies

 npm i stripe --save-dev

Creating a stripe singleton in our project

Once we have all the dependencies installed and environmental variables setup we can create our stripe singleton and initialize stripe there.

Create src/lib/stripe.ts

import Stripe from "stripe";
import { STRIPE_API_KEY } from "$env/static/private";

export const stripe = new Stripe(STRIPE_API_KEY, {
  apiVersion: "2024-04-10",
  typescript: true,
});

Creating the stripe webhook

After every event that we subscribed to stripe will fire a POST request to our webhook, in it we can handle the new subscriptions, expiring subscriptions and cancellations.

The following snippet might look long but it is very straight forward. To quickly break it down we:

  1. Verify the request signature to make sure its an authentic call from Stripe.
  2. Handle the checkout.session.completed event by adding relevant fields to the user object in MongoDB.
  3. Handle the customer.subscription.deleted event by setting hasAccess to false so we can use that for our subscription middleware later on and reject user access to subscriber only pages.

Create src/routes/api/stripe/webhook/+server.ts

import type { Stripe } from 'stripe';
import { stripe } from '$lib/stripe';
import { clientPromise } from '$lib/db';
import { json } from '@sveltejs/kit';
import { STRIPE_WEBHOOK_SECRET } from '$env/static/private';

export async function POST({ request }: { request: Request }) {
	const body = await request.text();
	const signature = request.headers.get('Stripe-Signature') as string;

	let event: Stripe.Event;

	try {
		event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
	} catch {
		return json(
			{ CODE: 'INVALID_SIGNATURE', message: 'The signature is invalid.' },
			{ status: 400 }
		);
	}

	const session = event.data.object as Stripe.Checkout.Session;
	const eventType = event.type;
	const client = await clientPromise;
	const db = client.db();

	try {
		switch (eventType) {
			case 'checkout.session.completed': {
				const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
				if (!session?.customer_details?.email) {
					return json(
						{ CODE: 'MISSING_METADATA', message: 'The session metadata is missing.' },
						{ status: 400 }
					);
				}
				const db = client.db();
				await db.collection('users').findOneAndUpdate(
					{ email: session.customer_details.email },
					{
						$set: {
							stripeSubscriptionId: subscription.id,
							stripeCustomerId: subscription.customer as string,
							stripePriceId: subscription.items.data[0].price.id,
							stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
							hasAccess: true
						}
					}
				);
				break;
			}

			case 'customer.subscription.deleted': {
				const subscription = await stripe.subscriptions.retrieve(session.id);
				await db
					.collection('users')
					.findOneAndUpdate(
						{ stripeCustomerId: subscription.customer },
						{ $set: { hasAccess: false } }
					);
				break;
			}

			default:
		}
	} catch (e: unknown) {
		console.error('stripe error: ' + e + ' event type: ' + event.type);
	}
	return json({ received: true }, { status: 200 });
}

Setup subscriber only routes.

With Stripe and our database set up, we can create a pricing page and protect admin routes by verifying that the user is logged in and has an active subscription. If the user is not logged in, we redirect them to the login page. If they are logged in but don’t have an active subscription, we show the pricing page and prompt them to subscribe.

Please ensure you have created a Stripe Product in your account.

Create a pricing route

To keep this brief, I won’t detail creating product pricing tables or other common UI elements. For our demo, we’ll use a simple prompt asking users to subscribe. If they’re not logged in, we’ll redirect them to the login page. Otherwise, we’ll send them to Stripe with a prefilled email using a Payment Link.

src/routes/pricing/+page.svelte

<script>
	import { page } from '$app/stores';
</script>

<div class="hero min-h-[50vh] bg-primary text-primary-content">
	<div class="hero-content text-center">
		<div>
			<h1>Subscribe to access your dashboard</h1>
			{#if $page.data?.session?.user}
				<a
					class="btn btm-primary"
					target="_blank"
					href={'https://buy.stripe.com/test_123456789' +
						'?prefilled_email=' +
						$page.data.session?.user?.email}>Subscribe</a
				>
			{:else}
				<a class="btn btm-primary" target="_blank" href="/auth/signin">Subscribe</a>
			{/if}
		</div>
	</div>
</div>

Modify the auth.ts to extend fields which are shared in each session

auth.ts

// under the providers array place the following snipper
	callbacks: {
		async session({ session, user }) {
			// for demonstration purposes im adding all user fields to the session
			// a better approach would be to cherry pick only fields you want to expose
			session.user = user;
			return session;
		}
	},

Modify the hooks.server.ts

The change here is very straight forward, after we made our hasAccess field available in the user information we just simply need to add an if statement in our authorizationHandle method to verify user have access to the page.

hooks.server.ts

///...
async function authorizationHandle({ event, resolve }) {
  ///...
  const session = await event.locals.auth();
  if (!session) {
    throw redirect(303, "/auth/signin");
  }
  // new snippet:
  if (!session?.user?.hasAccess) {
    console.log("user has no active subscription redirect to /pricing");
    throw redirect(303, "/pricing");
  }
  // ...
}

Thats all!

And there you have it! You’ve successfully set up a basic SaaS project with SvelteKit, Auth.js, and Stripe, complete with user authentication, subscription management, and protected routes. Now, the only thing left to decide is what your SaaS will be!

If you have any questions or suggestions feel free to drop me a message on twitter

svelte auth.js stripe saas