⚠️Draft Content

Storyblok Raises $80M Series C - Read News

What’s the True Total Price of Enterprise CMS? Find out here.

Skip to main content

Managing User Comments and Gated Content with Astro DB and Storyblok

Introduction

In a real-world application, content alone isn't sufficient. You need to manage users, enable interactions, and sometimes restrict access to exclusive content. Traditional CMS platforms like WordPress offer built-in solutions for these needs but lack the benefits of the headless approach.

Storyblok is a Headless CMS that offers a modern content editing experience. It includes many features that help content creators work efficiently but lacks built-in options for managing end users or subscribers. This limitation is by design, but you might need these features for your projects.

Astro is a popular framework, and we have a comprehensive guide on creating a Storyblok website with Astro. In this tutorial, we will use Astro with Astro DB to manage users, comments, and gated content, requiring users to log in to view private articles.

Explore a live demo of the project here.

This tutorial is not for beginners. It assumes familiarity with both Astro and Storyblok. We won’t cover the basics, so if you're not comfortable with these tools, please review our Astro Ultimate tutorial series first.

Note:

Each important section has a corresponding GitHub branch to help you follow along.

Setting Up Your Project

In Storyblok, create an Article content type with the following fields:

  1. title: Text
  2. excerpt: Textarea
  3. private: Boolean
  4. body: Richtext

Create a folder called articles and add a few articles, making sure some are marked as private. For the code, use the starting-point branch from the GitHub repo. The project includes routes for a homepage displaying all posts and individual article pages. Signup and Signin routes are styled but non-functional placeholders.

Astro gated content website home page

Astro gated content website home page

Crafting the Database Schema

We will create three tables in Astro DB:

Database schema diagram

Database schema diagram

With these three tables, we can implement authentication and commenting features for our website. Let's explore how to create an Astro DB and design this schema.

Integrating Astro DB

Astro's guide includes a section on Astro DB, which is helpful if you're not familiar with it. You can add a local database to your project by running:

        
      npx astro add db
    

While it's not yet connected to the online Astro Studio, we can start adding tables to our local database. Later, we can use the Astro CLI to push this schema to Astro DB and get our project running with Astro Studio.

DB Schema Definition in Astro

After running the astro add db CLI command, you'll have a db folder in your project's root directory with a config.ts and a seed.ts. Replace these files as follows:

db/config.ts
        
      import { defineDb, defineTable, column, NOW } from "astro:db";
const Comment = defineTable({
  columns: {
    id: column.text({
      primaryKey: true,
    }),
    publishedAt: column.date({
      default: NOW,
    }),
    body: column.text(),
    userId: column.text({
      references: () => User.columns.id,
    }),
    articleId: column.text(),
  },
});
const User = defineTable({
  columns: {
    id: column.text({ primaryKey: true }),
    username: column.text({
      unique: true,
    }),
    name: column.text(),
    passwordHash: column.text(),
  },
});
const Session = defineTable({
  columns: {
    id: column.text({ unique: true }),
    expiresAt: column.number({ optional: false }),
    userId: column.text({ optional: false, references: () => User.columns.id }),
  },
});
export default defineDb({
  tables: { Comment, User, Session },
});
    

We have replaced the default schema with the three-table schema we introduced in our database schema design section. As for the seed.ts, let's leave it empty for now.

Implementing Lucia for Authentication

Lucia is an open-source, low-level authentication library. Astro recommends using Lucia in their documentation. Lucia also has a guide on how to set it up with Astro. While Lucia's guide is based on SQLite and not directly on Astro DB, it offers a great overview of how everything works. We recommend taking a look at that guide.

        
      npm i lucia @lucia-auth/adapter-drizzle @node-rs/argon2
    

Let's create a new file src/lib/auth.ts and add the following code:

src/lib/auth.ts
        
      import { Lucia } from "lucia";
import { db, Session, User } from "astro:db";
import { DrizzleSQLiteAdapter } from "@lucia-auth/adapter-drizzle";
const adapter = new DrizzleSQLiteAdapter(db as any, Session, User);
export const lucia = new Lucia(adapter, {
  sessionCookie: {
    attributes: {
      secure: true,
    },
  },
  getUserAttributes: (attributes) => {
    return {
      username: attributes.username,
      name: attributes.name,
    };
  },
});
declare module "lucia" {
  interface Register {
    Lucia: typeof lucia;
    DatabaseUserAttributes: DatabaseUserAttributes;
    DatabaseSessionAttributes: DatabaseSessionAttributes;
  }
}
interface DatabaseUserAttributes {
  username: string;
  name: string;
}
interface DatabaseSessionAttributes {
  expiresAt: number;
  userId: string;
}
    

If you have read the Lucia authentication documentation, this code should look familiar. While the guide uses the BetterSQLite3Adapter, Astro DB is built with Drizzle ORM, so we use the @lucia-auth/adapter-drizzle instead. The rest of the code is primarily TypeScript helpers.

Next, to keep everything clean, let's create a few helper functions that we will use multiple times:

src/lib/helper.ts
        
      import { hash, verify } from "@node-rs/argon2";
import { generateId } from "lucia";
import { lucia } from "./auth";
import type { APIContext } from "astro";
//This is used for generating database column id
export function DBuuid() {
  return generateId(15);
}
const hashParameter = {
  memoryCost: 19456,
  timeCost: 2,
  outputLen: 32,
  parallelism: 1,
};
//Convert plain user password to a hash
export async function hashPassword(password: string) {
  return await hash(password, hashParameter);
}
//Validate plain user password with saved hash password
export async function validatePassword(hashPassword: string, password: string) {
  return await verify(hashPassword, password, hashParameter);
}
//Create session when you log in.
export async function createSession(userId: string, context: APIContext) {
  try {
    const oneHourFromNow = new Date();
    oneHourFromNow.setHours(oneHourFromNow.getHours() + 1);
    const session = await lucia.createSession(userId, {
      expiresAt: oneHourFromNow.getTime(),
      userId,
    });
    const sessionCookie = lucia.createSessionCookie(session.id);
    context.cookies.set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
  } catch (error) {
    console.log(error);
  }
}
//Just a wrapper around New Response
export function ErrorResponse(message: string, status: number = 400) {
  return new Response(
    JSON.stringify({
      error: message,
      sucess: false,
    }),
    {
      status,
    }
  );
}
//Basic username validation
export function isValidUsername(username: string): boolean {
  return typeof username === "string" && /^[a-z0-9_-]{3,31}$/.test(username);
}
//Basic password validation
export function isValidPassword(password: string): boolean {
  return typeof password === "string" && password.length >= 6 && password.length <= 255;
}
    

Most of the code will look familiar if you have read the Lucia Astro authentication guide.

Note:

You can check out db-auth-init branch to match the project progress.

Creating the Signup Functionality

Before we write the code for signup, let's quickly review our pages/signup.astro file. This file contains a form with three input fields: name, username, and password. When you submit the form, it makes a POST request to the /api/signup route. If you try it now, it will throw a 404 error since there is no /api/signup route yet. Let's create it.

src/pages/api/signup.ts
        
      import type { APIContext } from "astro";
import { db, eq, User } from "astro:db";
import {
  createSession,
  DBuuid,
  hashPassword,
  isValidPassword,
  isValidUsername,
  ErrorResponse,
} from "~/lib/helper";
export async function POST(context: APIContext): Promise<Response> {
  const formData = await context.request.formData();
  const name = formData.get("name");
  //name validation
  if (typeof name !== "string" || name.length < 3) {
    return ErrorResponse("Invalid name");
  }
  const username = formData.get("username") as string;
  const password = formData.get("password") as string;
  // Basic validation
  if (!isValidUsername(username) || !isValidPassword(password)) {
    return ErrorResponse("Invalid username or password");
  }
  //Check if already have account then return error.
  const [existingUser] = await db
    .select()
    .from(User)
    .where(eq(User.username, username));
  if (existingUser) {
    return ErrorResponse("Already have an account. Please login.");
  }
  //Hash user password for saving
  const passwordHash = await hashPassword(password);
  const userId = DBuuid();
  try {
    //Create user account
    await db.insert(User).values({
      id: userId,
      name,
      username,
      passwordHash,
    });
    //Once account is created we create Session
    await createSession(userId, context);
    return new Response(
      JSON.stringify({
        sucess: true,
      })
    );
  } catch (e) {
    return ErrorResponse("An unknown error occurred", 500);
  }
}
    

The above code is straightforward and includes comments to clarify its function. Here's the workflow:

  1. We get the form data: name, username, and password.
  2. We validate the input using the helper functions created earlier.
  3. We check the database for an existing user with the same username. If a user exists, we return an appropriate message.
  4. If the username is unique, we hash the password, save the user information in the database, and create a session.

Updating the Signup Form

Now, let's add a few lines of code to the pages/signup.astro file to connect the form to this API endpoint. Add the following JavaScript below the HTML markup:

pages/signup.astro
        
      <script>
  const errorMessageElement = document.getElementById("form-error")!;
  const form = document.getElementById("signup");
  form?.addEventListener("submit", async (e) => {
    e.preventDefault();
    errorMessageElement.innerText = "";
    const formElement = e.target as HTMLFormElement;
    const response = await fetch(formElement.action, {
      method: formElement.method,
      body: new FormData(formElement),
    });
    const data = await response.json();
    if (data.sucess) {
      window.location.href = "/";
    } else {
      errorMessageElement.innerText = data.error;
    }
  });
</script>
    

This script overrides the default form submit behaviour and makes a POST request via fetch. If the response is successful, it redirects to the home page. If there's an error, it displays the error message in the UI.

Building the Signin Functionality

The signin page will be almost identical to the signup page, with fields for username and password, and it will make a POST request to api/signin. Let's create the signin API endpoint with the following code:

pages/api/signin.ts
        
      import type { APIContext } from "astro";
import { db, eq, User } from "astro:db";
import {
  createSession,
  isValidPassword,
  isValidUsername,
  ErrorResponse,
  validatePassword,
} from "~/lib/helper";
export async function POST(context: APIContext): Promise<Response> {
  const formData = await context.request.formData();
  const username = formData.get("username") as string;
  const password = formData.get("password") as string;
  // Basic validation
  if (!isValidUsername(username) || !isValidPassword(password)) {
    return ErrorResponse("Invalid username or password");
  }
  //Check if user exist
  const [existingUser] = await db
    .select()
    .from(User)
    .where(eq(User.username, username));
  if (
    !existingUser ||
    !(await validatePassword(existingUser.passwordHash, password))
  ) {
    return ErrorResponse("Incorrect username or password");
  }
  await createSession(existingUser.id, context);
  return new Response(
    JSON.stringify({
      sucess: true,
    })
  );
}
    

This code is similar to the signup endpoint, with some differences:

  1. Validate the username and password.
  2. Check if a user with the given username exists. If not, return an appropriate response.
  3. Validate the password. If incorrect, return an appropriate response.
  4. If everything is correct, create a session and return a successful response.

Next, update the pages/signin.astro file. Let's add the same JavaScript we used on the signup page with one minor change: update the form ID as shown below.

pages/signin.astro
        
      const form = document.getElementById("signin");

    

Only this line will be different. To be fair, you could create a wrapper function in a utils file and reuse it, but I'll leave that part for you to implement.

Note:

You can check out the signin-signup branch to match the project progress.

Developing Middleware for Session Management

To handle session management effectively, we'll create a middleware that validates the session and stores session data in Astro's locals. This allows us to access user and session information across different pages and display varying UI based on authentication status.

src/middleware.ts
        
      import { verifyRequestOrigin } from "lucia";
import { defineMiddleware } from "astro:middleware";
import { lucia } from "~/lib/auth";
export const onRequest = defineMiddleware(async (context, next) => {
  //This is only because we are using https locally
  const isLocal = import.meta.env.RUNNING_LOCALLY === "yes";
  //This is to make sure we verify RequestOrigin
  if (context.request.method !== "GET" && !isLocal) {
    const originHeader = context.request.headers.get("Origin");
    const hostHeader = context.request.headers.get("Host");
    if (
      !originHeader ||
      !hostHeader ||
      !verifyRequestOrigin(originHeader, [hostHeader])
    ) {
      return new Response(null, {
        status: 403,
      });
    }
  }
  const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null;
  if (!sessionId) {
    context.locals.user = null;
    context.locals.session = null;
    return next();
  }
  const { session, user } = await lucia.validateSession(sessionId);
  if (!session) {
    const sessionCookie = lucia.createBlankSessionCookie();
    context.cookies.set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes
    );
  }
  //There is a bug I think, checking it manually
  if (session) {
    const isSessionFresh = session?.expiresAt.getTime() > new Date().getTime();
    session.fresh = isSessionFresh;
  }
  if (session && session.fresh) {
    const sessionCookie = lucia.createSessionCookie(session.id);
    context.cookies.set(
      sessionCookie.name,
      sessionCookie.value,
      sessionCookie.attributes
    );
  }
  context.locals.session = session;
  context.locals.user = user;
  return next();
});
    

In this middleware, we:

  1. Validate the request origin for non-GET requests.
  2. Retrieve the session ID from cookies.
  3. Validate the session and handle session renewal if necessary.
  4. Store session and user data in Astro's locals for use in pages.

Additional Configurations

Update TypeScript Definitions: Add the following code to env.d.ts to ensure TypeScript recognizes the session and user types:

env.d.ts
        
      declare namespace App {
  interface Locals {
    session: import("lucia").Session | null;
    user: import("lucia").User | null;
  }
}
    

Update Astro Configuration: For security, enable checkOrigin in astro.config.mjs:

astro.config.mjs
        
      export default defineConfig({
  ...
  security: {
    checkOrigin: true,
  },
});
    

Verifying the Setup

After setting up the middleware and making these configurations, you can test the authentication by logging in or signing up. Navigate to your home page and add the following code to check the session details:

        
      console.log(Astro.locals);
    

This will log the logged-in user details and session information to the console. With this, we've covered the most complex part of the setup. In the next part, we can focus on tweaking the UI to reflect authentication status.

Note:

You can check out middleware branch to match the project progress.

Updating the UI Based on User Authentication

Now that we have logged in the user, let's update our UI to reflect this. First, let's modify our Header.astro file. In the template, we already have some code that renders based on a predefined variable called user. This variable is set to null, so currently, the login link appears in the nav. If user is not null, it shows a profile page link and a sign-out link. Next to our site title, we also show the user name. We'll make this dynamic by setting user to Astro.locals.user.

src/components/Header.astro
        
      const user = Astro.locals.user;
    

Now, you will see changes in the header when you are logged in.

Let's quickly add the logout API route:

src/pages/api/signout.ts
        
      import type { APIContext } from "astro";
import { lucia } from "~/lib/auth";
export async function POST(context: APIContext): Promise<Response> {
  if (!context.locals.session) {
    return new Response(null, {
      status: 401,
    });
  }
  await lucia.invalidateSession(context.locals.session.id);
  const sessionCookie = lucia.createBlankSessionCookie();
  context.cookies.set(
    sessionCookie.name,
    sessionCookie.value,
    sessionCookie.attributes
  );
  return new Response(
    JSON.stringify({
      sucess: true,
    })
  );
}
    

Next, add the following JavaScript to the Header.astro component to handle the sign-out process:

src/component/Header.astro
        
      <script>
  const signoutForm = document.getElementById("signout");
  signoutForm &&
    signoutForm.addEventListener("submit", async (e) => {
      e.preventDefault();
      const formElement = e.target as HTMLFormElement;
      const response = await fetch(formElement.action, {
        method: formElement.method,
        body: new FormData(formElement),
      });
      const data = await response.json();
      if (data.sucess) {
        window.location.href = "/signin";
      }
    });
</script>
    

To prevent users from accessing private articles if they're not logged in, and to prevent logged-in users from accessing the signin and signup routes, add the following code:

In the signin and signup pages:

        
      if (Astro.locals.user) {
  return Astro.redirect("/");
}
    

In the articles page:

pages/articles/[slug].astro
        
      const user = Astro.locals.user;
if (story.content.private && !user) {
  return Astro.redirect("/signin");
}
    

With these changes, if an article is private and there is no logged-in user, attempting to access it will redirect the user to the signin page.

Now, you can test your project by signing in and out to ensure that the site is working as expected and that you can see the private content when logged in.

Note:

You can check out enhance-ui branch to match the project progress.

Displaying and Adding Comments to Articles

To display all the comments for an article, you can easily fetch them from the database. More details on reading from the database can be found here.

pages/articles/[slug].astro
        
      const comments = await db
  .select({
    id: Comment.id,
    publishedAt: Comment.publishedAt,
    body: Comment.body,
    name: User.name,
  })
  .from(Comment)
  .where(eq(Comment.articleId, story.uuid))
  .leftJoin(User, eq(Comment.userId, User.id));
    

For the UI, here's an example of how you might structure it. Feel free to apply your own styles to match your design preferences.

pages/articles/[slug].astro
        
      <div class="max-w-lg mx-auto px-4">
    {
      user ? (
        <CommentForm />
      ) : (
        <p class="font-bold text-xl">
          You have to{" "}
          <a href="/signin" class="underline text-indigo-600">
            signin
          </a>{" "}
          if you want to comment.
        </p>
      )
    }
    <h4 class="text-2xl font-bold my-6">Comments[{comments.length}]</h4>
    {
      comments.map(({ body, name }) => (
        <article class="flex gap-3 mb-6">
          <Avatar name={name} />
          <div class="flex-1">
            <p class="font-medium">{name}</p>
            <p>{body}</p>
          </div>
        </article>
      ))
    }
  </div>
    

In the code above, you'll notice that above the comments list, there's a section that checks if the user is signed in. If so, it displays a form for adding a comment; otherwise, it shows a sign-in link with a message encouraging the user to sign in to leave a comment.

The CommentForm component is simply a form with a textarea. You can easily replicate this with the following code:

src/components/CommentForm.astro
        
      //Add your own style or you can find a styled version in the github repo.
<form method="POST">
  <label for="body"> Write your comment</label>
  <textarea id="body" name="body" rows="3"> </textarea>
  <button type="submit">Save</button>
</form>
    

Now that we have the comment form displayed, let's ensure we save the data when a user leaves a comment.

pages/articles/[slug].astro
        
      //Create new Comment
if (Astro.request.method === "POST") {
  // parse form data
  const formData = await Astro.request.formData();
  const body = formData.get("body");
  if (typeof body === "string" && user) {
    // insert form data into the Comment table
    await db.insert(Comment).values({
      articleId: story.uuid,
      body,
      userId: user.id,
      id: DBuuid(),
    });
    return Astro.redirect(Astro.url, 303);
  }
}
    

More information on inserting data can be found in the Astro documentation. You can test your site by creating a few accounts, leaving different comments, and observing how it all works.

Note:

You can check out the add-comment branch to match the project progress.

Building the Profile Page

Lastly, let's create the missing profile page. Although the profile link is visible in the header when logged in, there isn't an actual profile page yet. Let's quickly create one:

src/pages/profile.astro
        
      ---
import { db, User, eq } from "astro:db";
import Layout from "~/components/Layout.astro";
import Input from "~/components/Input.astro";
import FormButton from "~/components/FormButton.astro";
const user = Astro.locals.user;
if (!user) {
  return Astro.redirect("/signin");
}
if (Astro.request.method === "POST") {
  const formData = await Astro.request.formData();
  const name = formData.get("name");
  if (typeof name === "string") {
    await db
      .update(User)
      .set({
        name,
      })
      .where(eq(User.id, user.id));
  }
  return Astro.redirect(Astro.url, 303);
}
---
<Layout>
  <main class="max-w-lg mx-auto my-20 p-4">
    <h1 class="text-4xl font-bold mb-4">Profile</h1>
    <form method="post">
      <label for="name">Name</label>
      <Input name="name" type="text" value={user.name} />
      <br />
      <FormButton name="Update profile" />
    </form>
  </main>
</Layout>
    

If you've followed along so far, the above code should be familiar. With this, all the features we aimed to implement are complete.

Note:

You can check out main branch to match the project progress.

Connecting to Astro Studio

Finally, let's connect our project to Astro Studio. This step is straightforward and will enable you to manage your database and project more efficiently. Follow the guide from the Astro documentation to easily connect your local database to Astro Studio. Once connected, you can use Astro Studio to manage your database schema, user data, and more. This integration will streamline your workflow and enhance the overall project management experience.

By following these steps, you'll have a fully functional system for managing user comments and gated content with Astro DB and Storyblok.

Conclusion

In this tutorial, we've covered the essentials of managing user comments and gated content using Astro DB and Storyblok. By combining these powerful tools, we've built a robust system for user authentication, comment management, and gated content. From designing the database schema to setting up authentication with Lucia and enhancing the UI based on authentication status, each step is crucial for a seamless user experience.

Feel free to explore further and customize the implementation to fit your specific needs. With the basics in place, you're well on your way to creating dynamic and interactive web applications with Astro and Storyblok.

Author

Dipankar Maikap

Dipankar Maikap

Dipankar is a seasoned Developer Relations Engineer at Storyblok, with a specialization in frontend development. His expertise spans across various JavaScript frameworks such as Astro, Next.js, and Remix. Passionate about web development and JavaScript, he remains at the forefront of the ever-evolving tech landscape, continually exploring new technologies and sharing insights with the community.