Edge Functions require Edge Database solutions

gyurmatag

György Márk Varga

Posted on July 11, 2023

Edge Functions require Edge Database solutions

Nowadays, users of web applications expect to reach the pages they want to visit as soon as possible, in a fraction of a second, even if that interface is not static content, but for example the result of some kind of database operation. For this, we need to use appropriate tools during development. In this article, we will learn about technologies with which we can create applications that meet the requirements described above and we're going to build an application that demonstrates this.

What are we going to build?

We are making a full stack web application, which will be a simple message board for a better understanding of the architecture, supported by Edge Functions and Edge Database, for the fastest possible operation.

Image description
But before we get into the middle of it, let's familiarize ourselves with the two building blocks of our small application (and the main topic of the article). Let me introduce you Edge Functions and the concept of Edge Database:

Edge functions

Edge Function solutions assure us that calculations can be placed as close to the users as possible. For instance, if a user from Germany wants to access our application, their request will not be routed to an American data center, but to a server park in Frankfurt, thereby significantly reducing data travel distance. This approach is similar to that of CDNs, but while CDNs focus on serving static data, edge functions handle dynamic data. Compared to serverless functions, the cold start phenomenon is not present here, and the distance between the data center and the end user is less, therefore it is more cost-efficient. However, this comes with a few drawbacks, for example, in the case of edge functions defined in Javascript, most Node.JS APIs are not supported, and our functions can be compressed to a maximum of 4MB (specifically in the Vercel environment).

Let's look at the simplified architecture of Edge functions:

Image description

The figure shows that even though the edge functions are geographically close to the user, if the database is in one region, the data has to travel a long way before it reaches the end user.
We need a solution for that.

Edge database

We rightly ask the question that it is great to bring computing as close as possible to the user, but what about those functions that also require database access? The server close to the user (where the function runs) will call into a database that is in a fixed geographic location. If the database is in America, and the user and computing are in Europe, then the data has to travel quite a long way unnecessarily. The user calls in to the server closest to him, and then that server makes a database request to another point on Earth. That's not very optimal, is it? This problem is solved by certain database solutions, which ensure that the replicas of the database are close to computing, always with fresh data.
Here we can use three different architecture:

Single-leader: Here, one main server receives information from clients, and other servers, or 'replicas', copy this data.

Multi-leader: In this setup, several servers receive information and act as models for the replicas.

No-leader: In this type, all servers can receive information and act as models for the replicas.

In this article we will use the Single-leader architecture, however with Turso we can hit any replica with a write request and it will proxy it to the primary DB.

Here is a figure, how edge functions look like with edge database solutions:

Image description

The implementation

Now that we understand the theoretical things, let's dive into the making of our little program and get to coding.
First of all, if you just want to get your hands on the source code, then I've got great news for you, the source code is available on Github. Also some of the code may not be included here in the article for the sake of simplicity, in that case you can refer to this repository.
We will use cutting-edge solutions in terms of frameworks and technologies. Let's see the stack:
Edge infrastructure: Vercel
Edge database solution: Turso
Database ORM: Drizzle
Full Stack Web Framework: Next.js
UI: TailwindCSS

As stated above, the app we will create will be a very simple message board, where after entering our name we can post a maximum of 255 characters (khmm...Twitter/Threads?), as well as display the posts written by others. This will perfectly illustrate how and with what efficiency our system works.
First, let's issue the command needed to build the Next.js application. Let's navigate to the folder where we want to put the application and type in our terminal:

npx create-next-app@latest

Next, name our application ("edge-post"), then proceed through the wizard with everything set to default.
Now that we have that, let's look at -and install into our program- the tools that are necessary to make our database and the compute server as close as possible to the user.

First, we need TursoDB! But what is it? TursoDB is a database solution based on the libSQL database, which is an open-contribution fork of SQLite. It was developed to minimize the response time of applications. This is particularly useful in cases where we use Edge functions (Vercel, Netlify, CloudFlare), which we discussed above. TursoDB also manages to return the data from the database replica that is physically closest to the edge function that calls it and thus to the user.

Unfortunately, we cannot use such a database solution using a graphical interface for the time being, we have to use the terminal on our machine. On MacOS and Linux environments, use Homebrew to issue the following command:

brew install chiselstrike/tap/turso
Enter fullscreen mode Exit fullscreen mode

...or you can use scripted install as well:

curl -sSfL https://get.tur.so/install.sh | bash
Enter fullscreen mode Exit fullscreen mode

If everything was OK, then we authenticate ourselves by issuing the following command:

turso auth signup
Enter fullscreen mode Exit fullscreen mode

This command opens a browser window in which we can log in with Github. If everything went well, we can create our DB for our application.

Interestingly, we can list the regions that are supported:

turso db locations
Enter fullscreen mode Exit fullscreen mode

Turso currently supports these locations:

Image description

The default location will be the one we are currently closest to.
So let's make our database:

turso db create edge-post-db
Enter fullscreen mode Exit fullscreen mode

Once our database is ready, we can even make a replica of it right away (the free tier allows 3 databases in total). It recommended the Virginia data center in the USA to me, so I made another replica there, as well as one in Paris, because I know that this is also a Vercel Edge compute location:

turso db replicate edge-post-db cdg
Enter fullscreen mode Exit fullscreen mode

And for the example of the figure at the beginning of the article, let's do one in Tokyo as well.

turso db replicate edge-post-db nrt
Enter fullscreen mode Exit fullscreen mode

That would be it, the next step is to retrieve the data belonging to the database, including the connection URL, which we use to connect to the DB.

turso db edge-post-db
Enter fullscreen mode Exit fullscreen mode

We can see the name, locations and URL of the database, which we need now. Copy the URL, then create an .env file in our project. Here it is:

DATABASE_URL=libsql://edge-post-db-[username].turso.io
Enter fullscreen mode Exit fullscreen mode

The .env file is responsible for storing these kind of key value pairs. Make sure to not commit your database URLs and secret keys to a GIT repository.

However, this will not be enough to connect to the database. We also need an authentication token, which we can create and retrieve as follows:

turso db tokens create edge-post-db
Enter fullscreen mode Exit fullscreen mode

Let's also put this in our .env file:

DATABASE_AUTH_TOKEN=<your auth token>
Enter fullscreen mode Exit fullscreen mode

Super! We are ready to set up our database. - Also we can use a basic web UI at turso.tech after logging in, but for the bigger part of the set up we have to use the CLI. - We can start dealing with dependencies within the project. How will we communicate with the database? No, not with Prisma, because it only runs with a workaround within Edge functions. We will use DrizzleORM, whose landing page shows that its developers have a very good sense of humor, but is their Open Source project great as well? Let's find out! Standing on the project, we can issue the command:

npm install drizzle-orm @libsql/client
npm install -D drizzle-kit
Enter fullscreen mode Exit fullscreen mode

This command installs the Drizzle ORM itself and the LibSQL client. Furthermore, in order to get the appropriate autocompletes and developer help in a local environment, we also install the Drizzle Kit.
The next step is to create the data schemas, the data models, which define what our database will look like.
It will look like this:

Image description

To define this, standing on the root of the project, create a folder and inside it a schema.ts file with this code:

import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';

export const users = sqliteTable('users', {
        id: integer('id').primaryKey(),
        email: text('email').notNull(),
    }
);

export const posts = sqliteTable('posts', {
        id: integer('id').primaryKey(),
        text: text("text", { length: 256 }).notNull(),
        userId: integer('user_id').references(() => users.id),
    }
);
Enter fullscreen mode Exit fullscreen mode

The schema speaks for itself. We will have a table in which we will store users and a table where the posts related to them will be stored. So far everything is fine, now we have to somehow get these -for now empty- tables to appear in our database. Create the drizzle.config.ts file standing on the root of the project. Here we will define, among other things, where our schema file is located and what the connection URL of our database is. Let's create it like this:

import { Config } from "drizzle-kit";

export default {
    schema: "./db/schema.ts",
    out: "./migrations",
    driver: "turso",
    dbCredentials: {
        url: process.env.DATABASE_URL,
       authToken: process.env.DATABASE_AUTH_TOKEN
    }
} satisfies Config;
Enter fullscreen mode Exit fullscreen mode

Now, in the package.json file, under 'scripts', enter the commands for generating and pushing the schema:

"generate": "drizzle-kit generate:sqlite",
"push": "drizzle-kit push:sqlite"
Enter fullscreen mode Exit fullscreen mode

We run them in this order and if we didn't get an error, we already have the tables in our database in the cloud. If we don't believe this, issue the command in our terminal that allows us to enter the database shell:

turso db shell edge-post-db
Enter fullscreen mode Exit fullscreen mode

Then issue this command:

.schema
Enter fullscreen mode Exit fullscreen mode

And we already see the two CREATE TABLE.
Our schema is up in the database, that's fine, but we need a link inside the Next.js application where we can reach it. Standing on the root, create a lib folder, and in it a file with the name turso.ts:

import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
const client = createClient({ url: process.env.DATABASE_URL, authToken: process.env.DATABASE_AUTH_TOKEN });
export const db = drizzle(client);
Enter fullscreen mode Exit fullscreen mode

This will give us an exported copy of our database access that we can then use on the endpoints.

By the way if you want to manage your database data using a GUI, you are able to with Drizzle Studio. All you need to do is modify the 'target' in the tsconfig.json file to this:

"target": "esnext",
Enter fullscreen mode Exit fullscreen mode

and launch this command on the root of the project:

npx drizzle-kit studio
Enter fullscreen mode Exit fullscreen mode

Now you have a GUI of your Turso SQLite database.

So talking about endpoints... let's start working a little on the Next.js project itself, creating these endpoints within it too... We'll need two: a POST endpoint to create a post and a GET endpoint to query the posts. First, let's create the endpoint where we can create a post. Here we need to reach: /api/posts/add, so accordingly in the app directory we create a post and then an add folder in it and a route.ts file with the following content:

import { NextRequest, NextResponse } from 'next/server'
import { db } from "@/lib/turso";
import { posts, users } from "@/db/schema";

export async function POST(req: NextRequest) {
    const { name, text } = await req.json()

    const newUser = await db.insert(users).values({ name }).returning().get()
    const newPost = await db.insert(posts).values({ text, userId: newUser.id }).returning().get()

    return NextResponse.json(newPost)
}
Enter fullscreen mode Exit fullscreen mode

Here, we create a new user and a post for each post. This can be solved better with authentication in the future, as long as there is no Drizzle - NextAuth integration, we won't go into it, and authentication is not the scope of this article anyway.
In the meantime, let's also develop the endpoint that is responsible for listing the posts in a scrollable way. This will be the same in the api/posts folder in a get folder in a route.ts file:

import { NextResponse } from 'next/server'
import { db } from "@/lib/turso";
import { posts } from "@/db/schema";

export async function GET(req) {
    const page = req.query?.page ? parseInt(req.query.page) : 1;
    const limit = req.query?.limit ? parseInt(req.query.limit) : 10;
    const offset = (page - 1) * limit;

    const fetchedPosts = await db.select().from(posts).limit(limit).offset(offset).all();
    return NextResponse.json(fetchedPosts)
}
Enter fullscreen mode Exit fullscreen mode

After the implementation of the endpoints, the spectacular part of the application can come: the interface. For this, we will use TailwindCSS with noble simplicity.
Our app will simply consist of 3 self-explanatory components. This is how we create a folder called 'components', where we will have a PostForm.tsx component on the one hand:

"use client";

import { useRouter } from "next/navigation";
import { addPost } from "@/app/actions";

export default function PostForm() {
  const router = useRouter();

  const handleAddPost = async (formData: FormData) => {
    await addPost(formData);
    router.refresh();
  };

  return (
    <div className="mx-auto w-full max-w-xs md:max-w-md lg:max-w-lg">
      <form
        action={handleAddPost}
        className="mb-4 rounded-lg bg-white px-4 pb-8 pt-6 shadow-md"
      >
        <div className="mb-4">
          <label
            className="mb-2 block text-sm font-bold text-gray-700"
            htmlFor="name"
          >
            Name
          </label>
          <input
            className="focus:shadow-outline w-full appearance-none rounded-lg border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
            id="name"
            name="name"
            type="text"
            placeholder="Name"
            required
          />
        </div>
        <div className="mb-6">
          <label
            className="mb-2 block text-sm font-bold text-gray-700"
            htmlFor="message"
          >
            Your Post
          </label>
          <textarea
            className="focus:shadow-outline w-full appearance-none rounded-lg border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
            id="message"
            name="message"
            placeholder="What's happening?"
            required
          />
        </div>
        <div className="flex items-center justify-end">
          <button
            className="focus:shadow-outline rounded-lg bg-blue-500 px-4 py-2 font-bold text-white transition-colors duration-200 ease-in-out hover:bg-blue-700 focus:outline-none"
            type="submit"
          >
            Post
          </button>
        </div>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here we practically define a form that calls a Server Action for submit action. With the help of this component, we will be able to submit a post. For this (as described in the Next.js documentation) - since this is a client component - we also define server actions in a separate file in the app directory, which will be called actions.ts and will look like this:

"use server";

export async function addPost(formData: FormData) {
  const formDataObject = Object.fromEntries(formData.entries());

  await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/posts/add`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: formDataObject.name,
      text: formDataObject.message,
    }),
  });
}
Enter fullscreen mode Exit fullscreen mode

This piece of code speaks for itself, we call our endpoint with the appropriate parameter and values.
You can see that we use an .env variable called NEXT_PUBLIC_APP_URL. Let's update our .env file with it too. So for the default port, we can add this line to the file:

NEXT_PUBLIC_APP_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Our next component is PostItem.tsx, which will represent a post card:

interface PostItemProps {
  authorName: string;
  postText: string;
}

export default function PostItem({ authorName, postText }: PostItemProps) {
  return (
    <div className="mx-auto mb-4 w-full max-w-xs rounded-lg bg-white px-4 py-6 shadow-md md:max-w-md lg:max-w-lg">
      <h2 className="mb-2 text-xl font-bold">{authorName}</h2>
      <p className="text-base text-gray-700">{postText}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

After that, the list in which we will display the posts can also come. PostList.tsx:

import PostItem from "./PostItem";
import { PostData } from "@/types/post";

interface PostListProps {
  postList: PostData[];
}

export default async function PostList({ postList }: PostListProps) {
  return (
    <div className="mx-auto w-full max-w-xs space-y-4 md:max-w-md lg:max-w-lg">
      {postList.map((postData, index) => (
        <PostItem
          key={index}
          authorName={postData.users.name}
          postText={postData.posts.text}
        />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Due to the proper typification, we would need two types here, so let's create a types folder in the root of the project, with two files in it.
post.ts:

import { User } from "@/types/user";

type Post = {
  text: string;
};

export type PostData = {
  users: User;
  posts: Post;
};

Enter fullscreen mode Exit fullscreen mode

user.ts:

export type User = {
  name: string;
};
Enter fullscreen mode Exit fullscreen mode

We coordinate these components in the page.tsx file along with passing the appropriate props, and calling the get endpoint:

import PostForm from "@/app/components/PostForm";
import PostList from "@/app/components/PostList";

export const runtime = "edge";

const getPosts = async () => {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_APP_URL}/api/posts/get`
  );
  return response.json();
};

export default async function Home() {
  const postList = await getPosts();
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-4 md:p-24">
      <PostForm />
      <PostList postList={postList} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Once we are done with these, start our application with the npm run dev command and we can see what we have created in a local environment.

In the end our project structure will look like this:

Image description

But how will this be deployed in an edge computing architecture? It will be very simple, let's see:

  1. We upload our code to Github
  2. We deploy the Github repo on Vercel and set the environment variables
  3. READY!

If everything went well, the following will be displayed in the deployment summary:

Image description

So now our endpoints and React Server Components run on Vercel's Edge Network. Not only these, but our database itself will be served from the right place, avoiding unnecessary round-trips.

Verdict

There you have it, a fully functional Edge Function-Edge Database application!

You can see that the paging is not implemented in the UI only in the API. Here is a challenge for you to implement this and make a pull request to the main repo.

Feel free to drop feedback in the comments section! If you get stuck or have questions please also use the comments section or reach out to me on Twitter.

💖 💪 🙅 🚩
gyurmatag
György Márk Varga

Posted on July 11, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related