Back to overview

Adding comments to my Astro blog with PlanetScale and Prisma on Vercel Edge

Person typing on a laptop
View count:

UPDATE (19 jun 2023): In the meantime I’ve started using GisCus for comments, read more about why and how here.

After I created my blog platform using Astro, the next thing on my list was to make it possible for people to (anonymously) leave comments on blog posts. For this I needed to add a database to my architecture.

Choice of technologies

I chose PlanetScale because it’s serverless and MySQL, 2 of my criteria. To communicate with my PlanetScale database, I chose to use Prisma, a Node.js and TypeScript ORM.

Set up Prisma

I started by adding the Prisma client: npm install prisma @prisma/client. After this install, I added my Prisma schema to my codebase:

src/schema.prisma
generator client {
  provider        = "prisma-client-js"
}

datasource db {
  provider     = "mysql"
  url          = env("DATABASE_URL")
  relationMode = "prisma"
}

model Post {
  id         Int       @id @default(autoincrement())
  createdAt  DateTime  @default(now())
  url        String
  like_count Int @default(0)
  Comment    Comment[]
}

model Comment {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  text      String
  author    String
  postId    Int
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
}

Then it got a bit tricky. Because I’m deploying my blog platform on Vercel’s Edge network, the connections from the platform to the database can not have a persistent connection. After some research, I found out Prisma offers a service to set up connection pooling called Prisma Data Platform. Once I had created an account on the Data Platform, I was able to create a Data Proxy, which provided me with a connection string to use in my application. This connection string is what I needed to put in the DATABASE_URL environment variable (which is used in the prisma.schema).

To generate the TypeScript types based on my Prisma schema, I just ran npx prisma generate, this will by default generate the types in the node_modules folder in your project locally.

Setting up PlanetScale

Syncing the schema from Prisma to PlanetScale is as easy as running npx prisma db push in your terminal.

Creating comments

To communicate from my frontend to my API routes, I opted to use TanStack Query, my favourite tool to handle client-side API calls in React. The frontend code to add and list comments for a blog post looks like this (this part I wrote in React):

src/components/Comments.tsx
import type { Comment } from "@prisma/client";
import { useQuery } from "@tanstack/react-query";
import { useRef, useState, Fragment } from "react";
const Comments = ({
  initialComments,
  blogUrl,
}: {
  initialComments?: Comment[];
  blogUrl: string;
}) => {
  const formRef = useRef<HTMLFormElement>(null);
  const [formState, setFormState] = useState<"idle" | "loading">("idle");
  const upToDateCommentsQuery = useQuery({
    queryKey: [`comments-${blogUrl}`],
    queryFn: async () => {
      const allCommentsInDb = await fetch(
        `/api/comments/list?blogUrl=${blogUrl}`,
      );
      const allCommentsInDbJson = await allCommentsInDb.json();
      return allCommentsInDbJson as Comment[];
    },
    initialData: initialComments,
  });
  const onSubmit = async (e: React.FormEvent) => {
    setFormState("loading");
    e.preventDefault();
    if (e.currentTarget) {
      const formData = new FormData(e.currentTarget as HTMLFormElement);
      formData.set("blogUrl", blogUrl);
      await fetch("/api/comments/create", {
        method: "POST",
        body: formData,
      });
      formRef.current?.reset();
      upToDateCommentsQuery.refetch();
    }
    setFormState("idle");
  };
  return (
    <Fragment>
      <h2 className="text-xl lg:text-2xl mb-4 font-bold">Add a comment</h2>
      <form
        onSubmit={onSubmit}
        ref={formRef}
        className="flex flex-col lg:w-[50%] items-start"
      >
        <label className="flex flex-col mb-2" htmlFor="author">
          Author
        </label>
        <input
          className="py-2 px-4 bg-white border-secondary border-2 rounded-lg w-full"
          placeholder="Author"
          name="author"
          required
        />
        <label className="flex flex-col mb-2 mt-4" htmlFor="comment">
          Comment
        </label>
        <textarea
          className="py-2 px-4 bg-white border-secondary border-2 rounded-lg w-full"
          placeholder="Comment"
          required
          rows={4}
          name="comment"
        ></textarea>
        <button
          disabled={formState === "loading"}
          className="px-8 mt-4 py-4 bg-secondary text-white rounded-lg lg:hover:scale-[1.04] transition-transform disabled:opacity-50 "
          type="submit"
        >
          {formState === "loading" ? "Submitting" : "Submit comment"}
        </button>
      </form>

      <h2 className="text-xl lg:text-2xl mb-4 font-bold">Comments</h2>
      {upToDateCommentsQuery?.data && upToDateCommentsQuery?.data.length > 0 ? (
        <div className="flex flex-col gap-y-4">
          {upToDateCommentsQuery?.data?.map((comment) => (
            <div key={comment.id} className="flex flex-col">
              <h3 className="font-bold">{comment.author}</h3>
              <div>{comment.text}</div>
            </div>
          ))}
        </div>
      ) : (
        <div className="mt-4">No comments yet. Be the first to add one!</div>
      )}
    </Fragment>
  );
};

export default Comments;

The code for the API route to create blogs looks like this:

src/pages/api/comments/create.ts
import type { APIRoute } from "astro";
import { prisma } from "../../../lib/prisma-client";

export const post: APIRoute = async ({ request }) => {
  const formData = await request.formData();
  const comment = formData.get("comment") ?? "";
  const author = formData.get("author") ?? "";
  const blogUrl = formData.get("blogUrl") ?? "";
  const blog = await prisma.post.findFirst({
    where: { url: blogUrl as string },
  });

  await prisma.comment.create({
    data: {
      author: author as string,
      text: comment as string,
      post: {
        connectOrCreate: {
          create: {
            url: blogUrl as string,
          },
          where: {
            id: blog?.id,
          },
        },
      },
    },
  });

  return new Response(null, {
    status: 200,
  });
};

And the code to list the comments for a post looks like this:

src/pages/api/comments/list.ts
import type { APIRoute } from "astro";
import { getCommentsForBlog } from "../../../lib/get-comments-for-blog";

export const get: APIRoute = async ({ request }) => {
  const params = new URLSearchParams(request.url.split("?")[1]);
  const allCommentsInDbForPost = await getCommentsForBlog(
    params.get("blogUrl"),
  );
  return new Response(JSON.stringify(allCommentsInDbForPost), {
    status: 200,
  });
};

Setting up the ‘Edge’ part

Deploying Astro to Vercel Edge is as easy as adding the Astro with Vercel integration and setting up the edge target. My astro config (note the edge in the import path):

astro.config.mjs
import { defineConfig } from "astro/config";
import vercel from "@astrojs/vercel/edge";

export default defineConfig({
  output: "server",
  adapter: vercel(),
});

I also had to configure an alias in Vite for the Prisma client to get it working on Vercel combined with Astro.

vite.config.js
export default {
  resolve: {
    alias: {
      ".prisma/client/edge": "./node_modules/.prisma/client/edge.js",
    },
  },
};

When building the application on Vercel, we also want to generate the client again to make sure the client is available in the node_modules there too. In the package.json I use "build": "prisma generate --data-proxy && astro build" for this. Instantiating the Prisma client in the code is done in lib/prisma-client.ts. I used lazy imports from Node.js here to make it work correctly locally and on Vercel.

src/lib/prisma-client.ts
import type { PrismaClient } from "@prisma/client";

let prisma: PrismaClient;

if (process.env.NODE_ENV === "development") {
  import("@prisma/client").then((mod) => (prisma = new mod.PrismaClient()));
} else {
  import("@prisma/client/edge").then(
    (mod) => (prisma = new mod.PrismaClient()),
  );
}
export { prisma };

To make my code run locally too, I needed to change the DATABASE_URL environment variable to make it point directly to PlanetScale instead of going through Prisma Proxy.
Go check it out on my blog, and add a comment ;-). Source code can be found on my Github.

Did you like this post? Check out my latest blog posts:

Astral picture
29 Mar 2024

Astro DB: Migrating my analytics data from Vercel Postgres

5 min reading time

  • astro
  • database
  • vercel
  • postgres
  • libsql
Screenshot of an analytics dashboard
16 Jan 2024

Basic analytics with Vercel Postgres - Drizzle - Astro

10 min reading time

  • vercel
  • databases
  • typescript
  • astro
Page of a dictionary
15 Nov 2023

A minimal dependency-free translation system for Next.js

6 min reading time

  • nextjs
  • react
  • javascript