Skip to content

Latest commit

 

History

History
969 lines (731 loc) · 34.6 KB

building-protected-and-paywall.mdx

File metadata and controls

969 lines (731 loc) · 34.6 KB
title description image author date published slug ogImage
Build authenticated and paywall pages with Stripe and Xata
Learn how to build protected and paywall pages using using Astro, Stripe, Lucia Auth, Xata and Vercel.
Rishi Raj Jain
04-10-2024
true
authenticated-paywall-stripe-xata

In this post, you'll learn how to authenticate users using Xata, create Stripe checkouts and respond to Stripe webhooks in an Astro application.

  • Set up Xata
  • Create a schema with different column types
  • Programmatically create Stripe checkout sessions
  • Respond to Stripe webhooks
  • Authenticate users with Lucia Auth

Before you begin

Prerequisites

You'll need the following:

Tech Stack

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.

Steps

To complete this guide, you will need to follow these steps:

Create a new Astro application

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.

Add Tailwind CSS to 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.

Enabling Server Side Rendering in Astro

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.

Setting up a Xata Database

After you've created a Xata account and are logged in, create a database by clicking on + Add database.

Create Xata database

Install the Xata CLI

Obtain the Xata initialization command by clicking on a table (say, user), and then on the Get code snippet button.

View User Table

A modal titled Code snippets is presented which contains a set of commands to integrate Xata into your project, locally.

Code Snippet for Xata CLI

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.

Setup Astro project with Xata CLI

Now, let’s move onto creating API endpoints that create checkouts dynamically using Stripe.

Create Stripe Checkout Sessions

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 &rarr;</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:

  • Imports the Stripe SDK and the type APIContext by Astro.
  • Exports a POST HTTP request handler.
  • Validates the presence of STRIPE_SECRET_KEY environment variable.
  • Creates a new stripe object using the secret key and the latest api version.
  • Creates a checkout with a given item adjusted to be for free. Having a checkout containing free items will help you quickly test a purchase flow manually.
  • Redirects user to the newly created checkout's URL.

Let's move on to handling the webhook requests triggered by Stripe if there's a successful purchase on the payment link.

Respond to Stripe Webhooks

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:

  • Imports the Stripe class from its SDK, APIContext type by Astro and getXataClient to access the in-memory Xata instance.
  • Exports a POST HTTP handler to only respond to incoming POST request(s).
  • Validates the presence of STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SIG environment variables.
  • Creates a Stripe webhook event from the incoming Stripe webhook request body.
  • Checks for an existing record with the email entered during the checkout.
  • If a record is found, the paid attribute is set to true.
  • Otherwise, it creates a record with the email and paid attribute as true.

Let's move on to authenticating users with your Xata database and a Lucia library.

Authenticating users with Lucia Auth and Xata

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:

  • Imports dotenv which makes all the environment variables accessible via the process.env object.
  • Uses vite's optimizeDeps to exclude the 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.

Building a Xata Adapter for Lucia Auth

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.

Initialize Lucia with Xata

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:

  • Imports the Lucia and XataAdapter classes.
  • Creates a new instance of XataAdapter as adapter.
  • Uses the adapter to create a new Lucia instance wherein the session cookie is set to Secure if the application is running in the production environment.
  • The 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.

Create Utilities for User Authentication Status

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:

  • Imports the lucia instance and the User type by Lucia.
  • Exports a getSessionID function which uses Lucia's readSessionCookie utility to obtain the session ID.
  • Exports a 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.

Building Authentication Routes with Lucia Auth

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.

Build the Sign Up HTML and API Route

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:

  • Imports a utility from the Lucia SDK to create random ID(s), the getXataClient utility to obtain the in-memory Xata instance, Argon2id class by oslo to hash given password(s).
  • Exports a POST HTTP handler to only respond to incoming POST request(s).
  • Obtains the email and password from the request's form data.
  • Hashes the password using Argon2id's hash method.
  • Retrieves a single matching record using the email from form data.
  • If no such record is found, it creates a record in the user table with the email, user id and hashed password.
  • Otherwise, if the hashed password is present in the matching record, it redirects to /signin route. Otherwise, it updates the existing record with the newly signed up user.
  • Creates a session using createSession helper utility by Lucia and sets the cookie.
  • Redirects to the index route.

Build the Sign In HTML and API Route

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:

  • Imports the initialized lucia instance and getXataClient helper utility.
  • Exports a POST HTTP handler to only respond to incoming POST request(s).
  • Obtains the email and password from the request's form data.
  • Looks for an existing record with the email in user table of your database.
  • If not found, it returns a 400 Bad Request response.
  • Otherwise, it uses Argon2id to verify the hashed password.
  • If the hashed password does not match, it returns a 400 Bad Request response.
  • Otherwise, it creates a session using createSession helper utility by Lucia and sets the cookie.
  • Redirects to the index route.

Build the Sign Out API Route

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:

  • Imports the initialized lucia instance and getSessionID helper utility.
  • Exports a GET HTTP handler to only respond to incoming GET request(s).
  • Invalidates the current user session using the invalidateSession utility by Lucia.
  • Creates an empty user session cookie using createBlankSessionCookie utility by Lucia.
  • Sets the blank user session cookie.
  • Redirects to the index route.

Create Protected and Paid Routes

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:

  • Checks if there's a user associated with the session and if they have paid.
  • If the above is not true, it returns a 403 Unauthorized response. Otherwise, it returns an HTML page containing the paid and protected information.

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:

  • Checks if there's a user associated with the session.
  • If the above is not true, it returns a 403 Unauthorized response. Otherwise, it returns an HTML page containing the protected information.

Deploy to Vercel

The repository, is now ready to deploy to Vercel. Use the following steps to deploy:

  • Start by creating a GitHub repository containing your app's code.
  • Then, navigate to the Vercel Dashboard and create a New Project.
  • Link the new project to the GitHub repository you've just created.
  • In Settings, update the Environment Variables to match those in your local .env file.
  • Deploy! 🚀

More Information

For more detailed insights, explore the references cited in this post.

What’s next?

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 🦋