Learn how to build protected and paywall pages using using Astro, Stripe, Lucia Auth, Xata and Vercel.
Written by
Rishi Raj Jain
Published on
April 10, 2024
In this post, you'll learn how to authenticate users using Xata, create Stripe checkouts and respond to Stripe webhooks in an Astro application.
You'll need the following:
The following technologies are used in this guide:
Technology | Description |
---|---|
Xata | Serverless Postgres database platform for scalable, real-time applications. |
Astro | Framework for building fast, modern websites with serverless backend support. |
Stripe | A platform to accept payments globally. |
Lucia Auth | An authentication library that abstracts away the complexity of handling sessions. |
To complete this guide, you will need to follow these steps:
Let’s get started by creating a new Astro project. Open your terminal and run the following command:
pnpm create astro@latest protected-and-paid-stripe-xata
pnpm create astro
is the recommended way to scaffold an Astro project quickly.
When prompted, choose:
Empty
when prompted on how to start the new project.Yes
when prompted if you plan to write using Typescript.Strict
when prompted how strict Typescript should be.Yes
when prompted to install dependencies.Yes
when prompted to initialize a git repository.Once that’s done, you can move into the project directory and start the app:
cd protected-and-paid-stripe-xata
pnpm dev
The app should be running on localhost:4321. Now, let's close the development server as we move on to integrating Tailwind CSS in the application.
For styling the app, you will use Tailwind CSS. Install and set up Tailwind at the root of your project's directory by running:
pnpm astro add tailwind
When prompted, choose:
Yes
when prompted to install the Tailwind dependencies.Yes
when prompted to generate a minimal tailwind.config.mjs
file.Yes
when prompted to make changes to the Astro configuration file.The command then finishes integrating TailwindCSS into your Astro project. It installed the following dependency:
tailwindcss
: TailwindCSS as a package to scan your project files to generate corresponding styles.@astrojs/tailwind
: The adapter that brings Tailwind's utility CSS classes to every .astro
file and framework component in your project.Now, let's move on to enabling server side rendering in the application.
To create checkouts using the Stripe API dynamically, you will need to server-side render your Astro application. Enable server side rendering in your Astro project by executing the following command in your terminal:
pnpm add @astrojs/vercel
When prompted, choose:
Yes
when prompted to install the Vercel dependencies.Yes
when prompted to make changes to the Astro configuration file.The command above installed the following dependency:
@astrojs/vercel
: The adapter that allows you to server-side render your Astro application on Vercel.Now, let's move on to integrating Xata in the application.
After you've created a Xata account and are logged in, create a database by clicking on + Add database
.
Obtain the Xata initialization command by clicking on a table (say, user
), and then on the Get code snippet
button.
A modal titled Code snippets
is presented which contains a set of commands to integrate Xata into your project, locally.
For interacting with your Xata database using the TypeScript SDK, install the Xata CLI globally by executing the following command in your terminal:
# Installs the CLI globally
npm install -g @xata.io/cli
Then, link your Xata project to your Astro application by executing the following command in your terminal window:
# Initialize your project locally with the Xata CLI
xata init --db https://Rishi-Raj-Jain-s-workspace-80514q.us-east-1.xata.sh/db/astro-stripe-protected-and-paid
Use the following answers to the Xata CLI one-time setup question prompts to integrate Xata with Astro:
Yes
when prompted to add .env
to .gitignore
.src/xata.ts
when prompted to enter the output path for the generated code by the Xata CLI.Now, let’s move onto creating API endpoints that create checkouts dynamically using Stripe.
A Stripe checkout session, in this case, would refer to a payment link hosted by Stripe where users can pay for a given product.
Let's use the index route to display a buy button that would take users to the Stripe hosted checkout. Create an index route (src/pages/index.astro
) with the following code:
---
// File: src/pages/index.astro
## export const prerender = true
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body class="font-display overflow-x-hidden">
<form class="p-12" method="post" action="/api/stripe/checkout">
<button class="rounded border border-black px-4 py-2 duration-150 hover:scale-105">Get My Product →</button>
</form>
</body>
</html>
The code above creates an index route in the Astro application containing a form that POSTs to /api/stripe/checkout
. To handle the form submission, create a file src/pages/api/stripe/checkout.ts
with the following code:
// File: src/pages/api/stripe/checkout.ts
import Stripe from 'stripe';
import type { APIContext } from 'astro';
export async function POST({ redirect }: APIContext) {
const STRIPE_SECRET_KEY = import.meta.env.STRIPE_SECRET_KEY;
if (!STRIPE_SECRET_KEY) return new Response(null, { status: 500 });
const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: '2023-10-16' });
const session = await stripe.checkout.sessions.create({
mode: 'payment',
payment_method_configuration: 'pmc_1O2qH3SE9voLRYpuz5FLmkvn',
line_items: [
{
quantity: 1,
price_data: {
unit_amount: 0,
currency: 'usd',
product: 'prod_OqWkk7Rz9Yw18f'
}
}
],
success_url: 'http://localhost:4321'
});
return redirect(session.url);
}
The code above does the following:
POST
HTTP request handler.STRIPE_SECRET_KEY
environment variable.Let's move on to handling the webhook requests triggered by Stripe if there's a successful purchase on the payment link.
Upon a successful purchase, Stripe fires a webhook POST request. To handle the webhook requests in Astro via server-side code, create a src/pages/api/stripe/webhook.ts
file with the following code:
// File: src/pages/api/stripe/webhook.ts
import Stripe from 'stripe';
import { getXataClient } from '@/xata';
import type { APIContext } from 'astro';
// Process rawBody from the request Object
async function getRawBody(request: Request) {
let chunks = [];
let done = false;
const reader = request.body.getReader();
while (!done) {
const { value, done: isDone } = await reader.read();
if (value) {
chunks.push(value);
}
done = isDone;
}
const bodyData = new Uint8Array(chunks.reduce((acc, chunk) => acc + chunk.length, 0));
let offset = 0;
for (const chunk of chunks) {
bodyData.set(chunk, offset);
offset += chunk.length;
}
return Buffer.from(bodyData);
}
// Stripe API Reference
// https://stripe.com/docs/webhooks#webhook-endpoint-def
export async function POST({ request }: APIContext) {
try {
const STRIPE_SECRET_KEY = import.meta.env.STRIPE_SECRET_KEY;
const STRIPE_WEBHOOK_SIG = import.meta.env.STRIPE_WEBHOOK_SIG;
if (!STRIPE_SECRET_KEY || !STRIPE_WEBHOOK_SIG) return new Response(null, { status: 500 });
const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: '2023-10-16' });
const rawBody = await getRawBody(request);
let event = JSON.parse(rawBody.toString());
const sig = request.headers.get('stripe-signature');
try {
event = stripe.webhooks.constructEvent(rawBody, sig, STRIPE_WEBHOOK_SIG);
} catch (err) {
console.log(err.message);
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
}
if (event.type === 'checkout.session.completed' || event.type === 'payment_intent.succeeded') {
const email = event.data.object?.customer_details?.email;
if (email) {
const xata = getXataClient();
const existingRecord = await xata.db.user.filter({ email }).getFirst();
if (existingRecord) {
await xata.db.user.update(existingRecord.id, { paid: true });
} else {
await xata.db.user.create({ email, paid: true });
}
return new Response('marked the user as paid', { status: 200 });
}
return new Response('no email of the user is found', { status: 200 });
}
return new Response(JSON.stringify(event), { status: 404 });
} catch (e) {
return new Response(e.message || e.toString(), { status: 500 });
}
}
The code above does the following:
APIContext
type by Astro and getXataClient
to access the in-memory Xata instance.STRIPE_SECRET_KEY
and STRIPE_WEBHOOK_SIG
environment variables.paid
attribute is set to true
.paid
attribute as true
.Let's move on to authenticating users with your Xata database and a Lucia library.
To start authenticating users and managing their sessions, install Lucia and Oslo, for various authentication utilities by executing the following command in your terminal:
pnpm add lucia oslo dotenv
The above command installs the packages passed to the install
command. The libraries installed include:
lucia
: An open source authentication library that abstracts away the complexity of handling sessions.dotenv
: A library for handling environment variables.oslo
: A collection of authentication related utilities.// File: astro.config.mjs
+ import 'dotenv/config'
import node from '@astrojs/node'
import tailwind from '@astrojs/tailwind'
import { defineConfig } from 'astro/config'
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone',
}),
integrations: [tailwind()],
+ vite: {
+ optimizeDeps: {
+ exclude: ['oslo'],
+ },
+ },
})
The code additions above do the following:
process.env
object.oslo
library from being pre-bundled, effectively, using the library as is.Now, create a Lucia directory in the src directory by running the following command:
mkdir src/lucia
Let's now move on to creating a Xata adapter for our Lucia library.
Create a src/lucia/xata.ts
file with the following code:
// File: src/lucia/xata.ts
import { getXataClient } from '@/xata';
import type {
Adapter,
DatabaseSession,
RegisteredDatabaseSessionAttributes,
DatabaseUser,
RegisteredDatabaseUserAttributes,
UserId
} from 'lucia';
The code above imports the getXataClient
utility to retrieve the in-memory Xata instance. It then imports the relevant types from the lucia
module. Append the following code:
// File: src/lucia/xata.ts
// ...
interface UserSchema extends RegisteredDatabaseUserAttributes {
id: string;
}
interface SessionSchema extends RegisteredDatabaseSessionAttributes {
id: string;
user_id: string;
expires_at: Date;
}
The code above defines the following types:
UserSchema
: A type created with RegisteredDatabaseUserAttributes
type definition along with id
attribute as a string.SessionSchema
: A type created with RegisteredDatabaseSessionAttributes
type definition along with id
attribute as a string, user_id
attribute as a string and expires_at
attribute as a Date.Append the following code to handle data transformations to Lucia's expected structure:
// File: src/lucia/xata.ts
// ...
function transformIntoDatabaseSession(raw: SessionSchema): DatabaseSession {
const { id, user_id: userId, expires_at: expiresAt, ...attributes } = raw;
return {
id,
userId,
expiresAt,
attributes
};
}
function transformIntoDatabaseUser(raw: UserSchema): DatabaseUser {
const { id, ...attributes } = raw;
return {
id,
attributes
};
}
The code above defines the following methods:
transformIntoDatabaseSession
: returns the SessionSchema
object in the shape of DatabaseSession
type.transformIntoDatabaseUser
: returns the UserSchema
object in the shape of DatabaseUser
type.Append the following code to create the XataAdapter class:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
private controller = getXataClient();
// ...
}
The code above defines a class XataAdapter based on Lucia's Adapter class. Then, it sets a controller
private class variable equivalent to the Xata instance obtained from the getXataClient
method. Define a method to delete a session by appending the following code:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
public async deleteSession(sessionId: string): Promise<void> {
await this.controller.sql`DELETE FROM "session" WHERE id=${sessionId}`;
}
}
The code above defines a deleteSession
method that uses Xata SQL queries to delete a particular session from your database. Define a method to delete all the sessions for a user by appending the following code:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
public async deleteUserSessions(userId: UserId): Promise<void> {
await this.controller.sql`DELETE FROM "session" WHERE user_id=${userId}`;
}
}
The code above defines a deleteUserSessions
method that uses Xata SQL queries to delete all sessions pertaining to a particular user in your database. Define a method to retrieve all the sessions for a user by appending the following code:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
public async getUserSessions(user_id: UserId): Promise<DatabaseSession[]> {
const records = await this.controller.db.session.filter({ user_id }).getAll();
return records.map((val) => {
return transformIntoDatabaseSession(val);
});
}
}
The code above defines a getUserSessions
method that uses Xata SDK methods to retrieve all sessions pertaining to a particular user in your database. Define a method to create a session by appending the following code:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
public async setSession(databaseSession: DatabaseSession): Promise<void> {
await this.controller.db.session.create({
id: databaseSession.id,
user_id: databaseSession.userId,
expires_at: databaseSession.expiresAt,
...databaseSession.attributes
});
}
}
The code above defines a setSession
method that uses Xata SDK methods to insert a record in a session table in your database. Define a method to update a session by appending the following code:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
public async updateSessionExpiration(sessionId: string, expiresAt: Date): Promise<void> {
await this.controller.sql`UPDATE "session" SET expires_at=${expiresAt} WHERE id=${sessionId}`;
}
}
The code above defines an updateSessionExpiration
method that uses a Xata SQL query to update the expires_at
attribute in a particular session in your database. Define a method to delete all expired sessions by appending the following code:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
public async deleteExpiredSessions(): Promise<void> {
await this.controller.sql`DELETE FROM "session" WHERE expires_at <=${new Date()}`;
}
}
The code above defines a deleteExpiredSessions
method that uses a Xata SQL query to delete all the sessions whose expires_at
attribute have expired. Define a method to obtain a session by appending the following code:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
private async getSession(session_id: string): Promise<DatabaseSession | null> {
const records = await this.controller.db.session.filter({ session_id }).getFirst();
return transformIntoDatabaseSession(records);
}
}
The code above defines a getSession
method that uses Xata SDK methods to retrieve a single matching record for a given session from your database. Define a method to obtain a user associated with a session by appending the following code:
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
private async getUserFromSessionId(session_id: string): Promise<DatabaseUser | null> {
const theSession = await this.controller.db.session.filter({ session_id }).getFirst();
if (theSession) {
const { user_id } = theSession;
const getUser = await this.controller.db.user.filter({ user_id }).getFirst();
if (getUser) return transformIntoDatabaseUser(getUser);
}
return null;
}
}
The code above defines a getUserFromSessionId
method that uses Xata SDK methods to retrieve a single matching record for a given session from your database. If a session is found, it further queries to obtain the user associated with the session. Otherwise, it returns null
.
// File: src/lucia/xata.ts
// ...
export class XataAdapter implements Adapter {
// ...
public async getSessionAndUser(
sessionId: string
): Promise<[session: DatabaseSession | null, user: DatabaseUser | null]> {
const [databaseSession, databaseUser] = await Promise.all([
this.getSession(sessionId),
this.getUserFromSessionId(sessionId)
]);
return [databaseSession, databaseUser];
}
}
The code above defines a getSessionAndUser
method that uses the getSession
and getUserFromSessionId
methods to obtain both the user and session information associated with the particular session id.
The XataAdapter class is now ready to be used as Lucia's adapter.
To use Xata with Lucia, create a file index.ts
inside the lucia
directory with the following code:
// File: src/lucia/index.ts
import { Lucia } from 'lucia';
import { XataAdapter } from './xata';
const adapter = new XataAdapter();
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: import.meta.env.PROD
}
},
getUserAttributes: (attributes) => {
return {
paid: attributes.paid,
email: attributes.email
};
}
});
The code above does the following:
adapter
.adapter
to create a new Lucia instance wherein the session cookie is set to Secure
if the application is running in the production environment.lucia
instance defines the getUserAttributes
function to only fetch paid
and email
attributes from the user record(s).To create type definitions for the associated user and session, append the following code:
// File: src/lucia/index.ts
// ...
interface DatabaseUserAttributes {
email: string;
paid: boolean | null;
}
declare module 'lucia' {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
The code above defines the following types:
DatabaseUserAttributes
: An object that would contain an email
attribute as a string
, and paid
attribute as boolean
or null
.Lucia
: As the type of the lucia instance created.To retrieve the authentication status of the session, you will need to obtain the user information associated with the request. Create a file user.ts
with the following code:
// File: src/lucia/user.ts
import { lucia } from '.';
import type { User } from 'lucia';
import type { AstroCookies } from 'astro';
export function getSessionID(cookies: AstroCookies): string | null {
const auth_session = cookies.get('auth_session');
if (!auth_session) return null;
return lucia.readSessionCookie(`auth_session=${auth_session.value}`);
}
export async function getUser(cookies: AstroCookies): Promise<User | null> {
const session_id = getSessionID(cookies);
if (!session_id) return null;
const { user } = await lucia.validateSession(session_id);
return user;
}
The code above does the following:
lucia
instance and the User
type by Lucia.getSessionID
function which uses Lucia's readSessionCookie
utility to obtain the session ID.getUser
function which uses the getSessionID
function to obtain the session id. Then, it uses Lucia's validateSession
utility to obtain the user data information with the session.Let's move on to creating simple user authentication forms and the API routes to handle the authentication logic.
In this section, you will learn how to create simple forms in Astro, and use the form data in Astro endpoints to sign up, sign out and sign in the users in your application.
To serve responses to a /signup
route in Astro, create a src/pages/signup.astro
file with the following code:
---
// File: src/pages/signup.astro
export const prerender = true
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body class="font-display overflow-x-hidden">
<h1>Sign Up</h1>
<form method="post" action="/api/sign/up">
<label for="email">Email</label>
<input id="email" name="email" />
<label for="password">Password</label>
<input id="password" name="password" />
<button>Continue</button>
</form>
</body>
</html>
The code above creates a sign up route in the Astro application containing a form that POSTs to /api/sign/up
with the user's email and password. Create a src/pages/api/sign/up.ts
file with the following code to handle the form submission:
// File: src/pages/api/sign/up.ts
import { generateId } from 'lucia';
import { lucia } from '@/lucia/index';
import { getXataClient } from '@/xata';
import type { APIContext } from 'astro';
import { Argon2id } from 'oslo/password';
export async function POST({ request, redirect, cookies }: APIContext): Promise<Response> {
const xata = getXataClient();
const user_id = generateId(15);
const formData = await request.formData();
const email = (formData.get('email') as string).trim();
const password = formData.get('password') as string;
const hashed_password = await new Argon2id().hash(password);
const existingRecord = await xata.db.user.filter({ email }).getFirst();
if (existingRecord) {
if (existingRecord.hashed_password !== null) return redirect('/signin');
await xata.db.user.update(existingRecord.id, { user_id, hashed_password });
} else {
await xata.db.user.create({ email, user_id, hashed_password });
}
const session = await lucia.createSession(user_id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect('/');
}
The code above does the following:
getXataClient
utility to obtain the in-memory Xata instance, Argon2id class by oslo
to hash given password(s).hash
method./signin
route. Otherwise, it updates the existing record with the newly signed up user.createSession
helper utility by Lucia and sets the cookie.To serve responses to a /signin
route in Astro, create a src/pages/signin.astro
file with the following code:
---
// File: src/pages/signin.astro
export const prerender = true
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body class="font-display overflow-x-hidden">
<h1>Sign In</h1>
<form method="post" action="/api/sign/in">
<label for="email">Email</label>
<input id="email" name="email" />
<label for="password">Password</label>
<input id="password" name="password" />
<button>Continue</button>
</form>
</body>
</html>
The code above creates a sign in route in the Astro application containing a form that POSTs to /api/sign/in
with the user's email and password. Create a src/pages/api/sign/in.ts
file with the following code to handle the form submission:
// File: src/pages/api/sign/in.ts
import { lucia } from '@/lucia/index';
import { getXataClient } from '@/xata';
import type { APIContext } from 'astro';
import { Argon2id } from 'oslo/password';
export async function POST({ request, cookies, redirect }: APIContext): Promise<Response> {
const xata = getXataClient();
const formData = await request.formData();
const email = (formData.get('email') as string).trim();
const password = formData.get('password') as string;
const existingRecord = await xata.db.user.filter({ email }).getFirst();
if (!existingRecord) return new Response('Incorrect email or password', { status: 400 });
const validPassword = await new Argon2id().verify(existingRecord.hashed_password, password);
if (!validPassword) return new Response('Incorrect email or password', { status: 400 });
const session = await lucia.createSession(existingRecord.user_id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect('/');
}
The code above does the following:
lucia
instance and getXataClient
helper utility.Argon2id
to verify the hashed password.createSession
helper utility by Lucia and sets the cookie.To sign out users in your Astro application, create a src/pages/api/sign/out.ts
file with the following code:
// File: src/pages/api/sign/out.ts
import { lucia } from '@/lucia/index';
import type { APIContext } from 'astro';
import { getSessionID } from '@/lucia/user';
export async function GET({ cookies, redirect }: APIContext): Promise<Response> {
await lucia.invalidateSession(getSessionID(cookies));
const sessionCookie = lucia.createBlankSessionCookie();
cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
return redirect('/');
}
The code above does the following:
lucia
instance and getSessionID
helper utility.invalidateSession
utility by Lucia.createBlankSessionCookie
utility by Lucia.To restrict access to a page for only paid and authenticated users, you will obtain the user information using the utilities created earlier. Create a file src/pages/protected_and_paid.astro
with the following code:
---
// File: src/pages/protected_and_paid.astro
import { getUser } from '@/lucia/user'
const user = await getUser(Astro.cookies)
if (!user || user.paid !== true) return new Response(null, { status: 403 })
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body class="font-display overflow-x-hidden">
<span> Protected and Paid Content </span>
</body>
</html>
The code above creates a /protected_and_paid
route in the Astro application that does the following:
To restrict access to a page for only authenticated users, you will obtain the user information using the utilities created earlier. Create a file src/pages/protected.astro
with the following code:
---
import { getUser } from '@/lucia/user'
const user = await getUser(Astro.cookies)
if (!user) return new Response(null, { status: 403 })
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
</head>
<body class="font-display overflow-x-hidden">
<span> Protected Content </span>
</body>
</html>
The code above creates a /protected
route in the Astro application that does the following:
The repository, is now ready to deploy to Vercel. Use the following steps to deploy:
.env
file.For more detailed insights, explore the references cited in this post.
We'd love to hear from you if you have any feedback on this tutorial, would like to know more about Xata, or if you would like to contribute a community blog or tutorial. Reach out to us on Discord or join us on X | Twitter. Happy building 🦋
Xata provides the best free plan in the industry. It is production ready by default and doesn't pause or cool-down. Take your time to build your business and upgrade when you're ready to scale.
Sign up to our newsletter
By subscribing, you agree with Xata’s Terms of Service and Privacy Policy.
Copyright © 2024 Xatabase Inc.
All rights reserved.