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.
- Go to the Stripe Dashboard and navigate to the “Developers” section.
- Click on “Webhooks” in the left sidebar.
- Click on the “Add endpoint” button.
- 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
andcustomer.subscription.deleted
) - Click on “Add endpoint” to save the configuration.
- 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:
- Verify the request signature to make sure its an authentic call from Stripe.
- Handle the
checkout.session.completed
event by adding relevant fields to the user object in MongoDB. - Handle the
customer.subscription.deleted
event by settinghasAccess
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