32

Building a Serverless Application with Next.js and CockroachDB!

 2 years ago
source link: https://dev.to/harshhhdev/building-a-serverless-application-with-nextjs-and-cockroachdb-3pm5
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Cover image for Building a Serverless Application with Next.js and CockroachDB!
Harsh Singh

Posted on Mar 13

Building a Serverless Application with Next.js and CockroachDB!

Hey there! Hope you're having a wonderful day or night - today, we'll be building a simple Next.js serverless application deployed on Vercel, which uses CockroachDB for the backend serverless database.

Live app 👉 musiclovers.vercel.app
Repository 👉 gitlab/cockroach-serverless-with-next.js

Now, before we start, I'd like to answer the main question: out of all databases in the world, why are we using one named after a pest?

Well, let me break it down for you, here's a list of things which separate CockroachDB from other serverless databases, and what caused me to fall in love with it:

  1. Compatible with PostgreSQL ecosystem

    • CockroachDB uses Postgres-compatible SQL, meaning that for many developers like me, we can use tools directly from the PostgreSQL ecosystem, and migrating isn't a pain.
  2. You're not wasting a penny

    • CockroachDB's pricing is simple, and to the point. You get 5GB storage for free, which is plenty, along with $1 for every extra gigabyte of storage you use. Along with this, you get 250M request units monthly, and pay just $1 for every 10M extra request units. If this isn't a steal, I don't know what is.
  3. Avoid downtime

    • Behind the scenes, your data is replicated at least 3 times - meaning that you won't face downtime for things like availability zone outages, database upgrades, and security patches. Even schema changes are online!

...and as a side-note: no, this isn't sponsored by CockroachDB - although I will not turn down any sponsorships 😛

Introduction

Now that you know why I love CockroachDB, let's get into building our actual app.

Here's what we'll be making:

A simple, clean and dark web app deployed to Vercel where people can share music they like!

Getting Started

Let's kickstart our Next.js and TypeScript project!

npx create-next-app@latest --ts
# or
yarn create next-app --typescript

Enter fullscreen mode

Exit fullscreen mode

Let's start the server now.

cd music-lovers
yarn dev

Enter fullscreen mode

Exit fullscreen mode

Your server should be running on localhost

Let's now begin to write our Prisma data schema, and connect it with CockroachDB.

Start by installing prisma and @prisma/client

# Installs both as as development dependencies
yarn add prisma @prisma/client -D

Enter fullscreen mode

Exit fullscreen mode

Now, let's create a new file at prisma/schema.prisma and open it up.

Inside here, let's configure our datasource and client.

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["cockroachdb"]
}

datasource db {
  provider = "cockroachdb"
  url      = env("DATABASE_URL")
}

Enter fullscreen mode

Exit fullscreen mode

Since CockroachDB is just a preview feature as of now, we'll have to put it under "preview features". Check the Prisma list of supported databases if you're reading this post after a while, just to double-check if it's still in preview.

Right now, Prisma DOES NOT support migrating your schema into CockroachDB, for that reason we'll have to write it ourselves in old-fashioned SQL and import that into our schema instead.

Now, let's get to work on our schema. Since app this will be simple, we'll only have a single schema called post. For this, create a SQL file at root called dbinit.sql.

CREATE TABLE post (
    id SERIAL PRIMARY KEY NOT NULL,
    title VARCHAR(255) NOT NULL,
    content TEXT NOT NULL,
    link VARCHAR(255) NOT NULL,
    createdAt TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);

Enter fullscreen mode

Exit fullscreen mode

Now, let's create a CockroachDB database. Sign in, and hit "create cluster" on the clusters dashboard. Choose the "serverless" plan, with the region and provider of your choice, and name your cluster.

Inside your cluster, we'll start by creating a SQL user. Hit "add user", name your user and generate the password. Store the password somewhere safe, as you'll need it later on.

At top, hit "connect". Choose your operating system - I'm an Arch Linux user, so I'll go ahead with "Linux".

Follow the instructions on the modal - download the CRDB, and run the command, inputting in your password in the field which it asks.

Now, head into your terminal and run this to generate

cockroach sql --url "<connection-string>" --file dbinit.sql

Enter fullscreen mode

Exit fullscreen mode

Amazing! It should've worked to create the table inside of CockroachDB.

Now, let's pull your schema from there into your schema.prisma file. To do this, simply run yarn prisma db pull, and ta-da, you should see your schema generated in your file.

model post {
  id        BigInt   @id(map: "primary") @default(autoincrement())
  title     String   @db.VarChar(255)
  content   String
  link      String   @db.VarChar(255)
  createdat DateTime @default(now())
}

Enter fullscreen mode

Exit fullscreen mode

Now that we have that generated, let's run yarn prisma generate to generate the Prisma Client.

Now, we have one final step before we can start using Prisma inside of our Next.js application.

Create a new file, lib/prisma.ts. Inside of this, we'll include a global way of accessing Prisma throughout our application.

import { PrismaClient } from '@prisma/client'

declare global {
  var prisma: PrismaClient | undefined
}

const prisma = global.prisma || new PrismaClient()

if (process.env.NODE_ENV !== 'production') global.prisma = prisma

export default prisma

Enter fullscreen mode

Exit fullscreen mode

Cool! Now that we have our database setup, it's time to switch gears for a bit and add our custom global styles into this thing. Open styles/globals.css and customise the styling to your needs. Here's what I've got:

:root {
  --bg: #131313;
  --main: #f1f1f1;
}

* {
  padding: 0;
  margin: 0;
  box-sizing: border-box;
}

body {
  padding: 0;
  margin: 0;
  font-family: 'Fira Code', monospace;
  overflow-x: hidden;
  color: var(--main);
  background-color: var(--bg);
}

a {
  color: inherit;
  text-decoration: none;
}

::selection {
  background: var(--main);
  color: var(--bg);
}

::-webkit-scrollbar {
  width: 10px;
}

::-webkit-scrollbar-track {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: var(--main);
}

Enter fullscreen mode

Exit fullscreen mode

Since we're using a custom font, we need to create a new file under pages called _document.tsx, where we import the font.

import Document, { Html, Head, Main, NextScript } from 'next/document'

class Doc extends Document {
  render() {
    return (
      <Html>
        <Head>
          <link
            href='https://fonts.googleapis.com/css2?family=Fira+Code&display=swap'
            rel='stylesheet'
          />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}

export default Doc

Enter fullscreen mode

Exit fullscreen mode

Let's switch gears from styling, and go into our index.tsx to edit some things.

We'll remove the basic content, along with removing the imports up top for next/image and next/head.

import type { NextPage } from 'next'
import styles from '../styles/Home.module.css'

const Home: NextPage = () => {
  return (
    <div className={styles.container}>

    </div>
  )
}

export default Home

Enter fullscreen mode

Exit fullscreen mode

Let's now create a new file, under our components directory called Posts.tsx and import React, our styles, and Prisma.

import { FC } from 'react'
import styles from '../styles/Home.module.css'
import { post as PostType } from '@prisma/client'

Enter fullscreen mode

Exit fullscreen mode

Inside of here, let's setup our component.

const Posts: FC<{ posts: PostType[] }> = ({ posts }) => {
  return (
    <>
      {posts.map((post, i) => (
        <div className={styles.post} key={i}>
          <a href={`/post/${post.id}`} className={styles.title}>
            {post.title}
          </a>
          <p className={styles.content}>
            {post.content.length > 200
              ? post.content.substring(0, 200) + '...'
              : post.content}
          </p>
          <a
            href={post.link}
            target='_blank'
            rel='noreferrer'
            className={styles.link}
          >
            {post.link}
          </a>
        </div>
      ))}
    </>
  )
}

Enter fullscreen mode

Exit fullscreen mode

This basically takes in an array of posts as props, and maps them out.

Let's also add in some CSS to spice up this thing.

.post {
  color: var(--main);
  border: solid 1px #303030;
  width: inherit;
  padding: 20px 25px;
}

.post:hover {
  border: 1px solid #5c5c5c;
}

.post:focus {
  border: 1px solid var(--main);
}

.title {
  font-size: 24px;
  font-weight: 900;
  text-decoration: none;
}

.content {
  margin: 30px 0;
}

.title:hover {
  text-decoration: underline;
}

.link:hover {
  text-decoration: underline;
}

Enter fullscreen mode

Exit fullscreen mode

Awesome! Now, let's go back into our index.tsx file and strap all of this together.

Import lib/prisma into this file, and run prisma.findMany() inside getServerSideProps to return all posts to display on the screen.

export const getServerSideProps: GetServerSideProps = async (context) => {
  const posts = await prisma.post.findMany()

  return {
    props: {
      posts,
    },
  }
}

Enter fullscreen mode

Exit fullscreen mode

Now, add in the posts component here, passing the props which we got from our getServerSideProps function.

// ...
import Nav from '../components/Nav'
import { post as PostType } from '@prisma/client'

const Home: NextPage<{ posts: PostType[] }> = ({ posts }) => {
  return (
    <Post posts={posts} />
  )
}

Enter fullscreen mode

Exit fullscreen mode

Beware! You might run into a JSON serialising problem. To fix this, simply install the following packages:

yarn add superjson babel-plugin-superjson-next

Enter fullscreen mode

Exit fullscreen mode

Now, create a new file .babelrc and configure it for superjson:

{
  "presets": ["next/babel"],
  "plugins": ["superjson-next"]
}

Enter fullscreen mode

Exit fullscreen mode

Cool! We don't have anything displayed yet as our database tables are empty, but in the meantime, we can style our page to make it look awesome.

Let's create a Navbar component. Create a new file at components/navbar.

Let's import React, along with our styles and create a simple navigation bar.

import { FC } from 'react'
import styles from '../styles/Home.module.css'

const Nav: FC = () => {
  return (
    <nav className={styles.nav}>
      <Link href='/'>
        <h1>Share Music 🎷</h1>
      </Link>
      <Link href='/new'>
        <a className={styles.create}>New Post</a>
      </Link>
    </nav>
  )
}

export default Nav

Enter fullscreen mode

Exit fullscreen mode

Looking good! Let's add the navbar styles into our Home.module.css file.

.nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: inherit;
  margin-bottom: 50px;
}

.create {
  padding: 15px;
  color: var(--main);
  border: solid 2px var(--main);
  transition: 0.1s linear;
}

.create:hover {
  background-color: var(--main);
  color: var(--bg);
}

Enter fullscreen mode

Exit fullscreen mode

Looking good! Now, let's import this into our main file. Our home component should now include two parent divs, and our other components.

const Home: NextPage<{ posts: PostType[] }> = ({ posts }) => {
  return (
    <div className={styles.container}>
      <div className={styles.box}>
        <Nav />
        <Post posts={posts} />
      </div>
    </div>
  )
}

Enter fullscreen mode

Exit fullscreen mode

Let's go back to our Home.module.css file and add in the styles for the parent components.

.container {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 100vw;
}

.box {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 40vw;
  min-width: 600px;
  margin-top: 20px;
}

Enter fullscreen mode

Exit fullscreen mode

Alright, that's enough styling for now. Let's create a new file under pages called new.tsx.

Let's create a new Form component inside our components directory which includes the form for creating a new post.

import { FC } from 'react'
import styles from '../styles/New.module.css'

const Form: FC = () => {
  return (
    <form className={styles.form}>
      <input
        placeholder='Post title...'
        className={styles.input}
        type='text'
      />
      <input
        placeholder='Song link...'
        className={styles.input}
        type='url'
      />
      <textarea
        placeholder='Why you love this song...'
        className={styles.content}
      ></textarea>
      <button className={styles.create} type='submit'>
        Publish Post
      </button>
    </form>
  )
}

export default Form

Enter fullscreen mode

Exit fullscreen mode

Cool! Since this is a new page, we'll create a new file named New.module.css under the styles directory. This file will focus on styling the form components.

.form {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: inherit;
}

.input {
  width: inherit;
  padding: 10px;
  background-color: var(--bg);
  outline: none;
  border: 1px solid #303030;
  font-size: 16px;
  font-family: 'Fira Code', monospace;
  color: var(--main);
  margin: 20px 0;
  padding: 20px;
  transition: 0.1s linear;
}

.input:hover,
.content:hover {
  border: 1px solid #5c5c5c;
}

.input:focus,
.content:focus {
  border: 1px solid var(--main);
}

.content {
  width: inherit;
  font-size: 16px;
  font-family: 'Fira Code', monospace;
  color: var(--main);
  background-color: var(--bg);
  outline: none;
  border: 1px solid #303030;
  padding: 20px;
  margin: 20px 0;
  resize: none;
  height: 500px;
}

.create {
  padding: 15px;
  font-family: 'Fira Code', monospace;
  font-size: 16px;
  outline: none;
  color: var(--main);
  background: var(--bg);
  border: solid 2px var(--main);
  transition: 0.1s linear;
}

.create:hover {
  background-color: var(--main);
  color: var(--bg);
  cursor: pointer;
}

Enter fullscreen mode

Exit fullscreen mode

Now, once we have that done, let's bring it all together in our new.tsx file.

import type { NextPage } from 'next'

import styles from '../styles/Home.module.css'f

import Nav from '../components/Nav'
import Form from '../components/Form'

const New: NextPage = () => {
  return (
    <div className={styles.container}>
      <div className={styles.box}>
        <Nav />
        <Form />
      </div>
    </div>
  )
}

export default New

Enter fullscreen mode

Exit fullscreen mode

Whew! Now that that's over, let's work on making our form functional. For this, we'll create a file called new.ts under the pages/api directory.

Inside here, let's import Prisma and the required types from Next.js.

import type { NextApiRequest, NextApiResponse } from 'next'
import prisma from '../../lib/prisma'

Enter fullscreen mode

Exit fullscreen mode

Now, inside our function, we'll get title, content, and link from the request's body.

We'll then wrap our prisma.create function in a trycatch block, returning a status of 200 with the appropriate JSON fields if it succeeds, or a status of 509 and our error if it doesn't.

We'll also cut off the last digit of the post.id, as it returns a BigInt.

const newTrack = async (req: NextApiRequest, res: NextApiResponse) => {
  const { title, content, link } = req.body

  try {
    const post = await prisma.post.create({
      data: {
        title: title,
        content: content,
        link: link,
      },
    })

    const slug = post.id.toString().substring(0, post.id.toString().length)
    return res.status(200).json({ success: true, slug: slug })
  } catch (err) {
    return res.status(509).json({ error: err })
  }
}

export default newTrack

Enter fullscreen mode

Exit fullscreen mode

Now, to make all this work, let's go back to our components/Form.tsx file and create three new refs inside our Form function for getting different input fields.

  const titleRef = useRef<HTMLInputElement>(null)
  const linkRef = useRef<HTMLInputElement>(null)
  const contentRef = useRef<HTMLTextAreaElement>(null)

Enter fullscreen mode

Exit fullscreen mode

Assign the refs to the appropriate elements inside of the component.

Let's create a function called createPost in which we use fetch to POST data to our API.

  const createPost = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()

    const headers = new Headers()
    headers.append('Content-Type', 'application/json')

    const raw = JSON.stringify({
      title: titleRef.current?.value!,
      link: linkRef.current?.value!,
      content: contentRef.current?.value!,
    })

    const requestOptions: RequestInit = {
      method: 'POST',
      headers: headers,
      body: raw,
    }

    fetch('/api/new', requestOptions)
      .then((response) => response.json())
      .then((result) => console.log(result))
      .catch((error) => console.log('error', error))
  }

Enter fullscreen mode

Exit fullscreen mode

To purify the content inside of our input fields, let's use dompurify.

    const raw = JSON.stringify({
      title: dompurify.sanitize(titleRef.current?.value!),
      link: dompurify.sanitize(linkRef.current?.value!),
      content: dompurify.sanitize(contentRef.current?.value!),
    })

Enter fullscreen mode

Exit fullscreen mode

Let's also import next/router and setup the useRouter hook to redirect after the user creates a post.

  const router = useRouter()

  const createPost = (e: FormEvent<HTMLFormElement>) => {
  // ...

  fetch('/api/new', requestOptions)
    .then((response) => response.json())
    .then((result) => router.push(`/post/${result.slug}`))
    .catch((error) => console.log('error', error))
  }

Enter fullscreen mode

Exit fullscreen mode

Now, in our form element, we should add in an onSubmit function, where we call this method.

<form className={styles.form} onSubmit={(e) => createPost(e)}>

Enter fullscreen mode

Exit fullscreen mode

Let's try to create a new post now. Fill in all the fields, and hit "Publish Post" when you're done.

It'll redirect you to another page and send a 404 error, as we haven't built out the page yet. However, if we check our home page we should be able to see the post is created indeed! Give yourself a pat on the back if you've made it this far :)

So, what're we waiting for? Let's move onto the final step of this project, which is creating the "View" page.

Let's pass in the post as props, and build our component according to the contents of the post.

import { FC } from 'react'
import styles from '../styles/View.module.css'
import { post as PostType } from '@prisma/client'

const View: FC<{ post: PostType }> = ({ post }) => {
  return (
    <div className={styles.post}>
      <h1>{post.title}</h1>
      <div className={styles.info}>
        <a
          href={post.link}
          target='_blank'
          rel='noreferrer'
          className={styles.link}
        >
          {post.link}
        </a>
        <p>Written on {post.createdAt.toLocaleDateString()}</p>
      </div>
      <p>{post.content}</p>
    </div>
  )
}

export default View

Enter fullscreen mode

Exit fullscreen mode

...and let's add in our CSS wonderful styles:

.post {
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.info {
  display: flex;
  justify-content: space-between;
  margin: 30px 0;
}

.link:hover {
  text-decoration: underline;
}

Enter fullscreen mode

Exit fullscreen mode

Brilliant! Now, let's head back to our pages directory and create a new file at /post/[id].tsx.

Inside here, create the PostView component where our Nav and View components come together to form the page. Pass in post as a prop for this top-level component, as we'll be retrieving that from getServerSideProps

// ...
import styles from '../../styles/Home.module.css'

import Nav from '../../components/Nav'
import View from '../../components/View'

import { post as PostType } from '@prisma/client'

const PostView: NextPage<{ post: PostType }> = ({ post }) => {
  return (
    <div className={styles.container}>
      <div className={styles.box}>
        <Nav />
        <View post={post} />
      </div>
    </div>
  )
}

Enter fullscreen mode

Exit fullscreen mode

Let's move onto getting the post from our database inside getServerSideProps now. We'll use the BigInt constructor to turn our params into the BigInt type, using that to fetch a unique post.

export const getServerSideProps: GetServerSideProps = async (context) => {
  const post = await prisma.post.findUnique({
    where: {
      id: BigInt(context.params!.id!.toString()),
    },
  })

  return {
    props: {
      post,
    },
  }
}

Enter fullscreen mode

Exit fullscreen mode

Magnificent! Let's try opening up localhost:3000/post/1 - it should display the content of our post! If we try to create a new post now, it should display the contents of that too.

...and we're finished! WHOO-HOO! If you made it down here, good work! I'd love to hear your thoughts in the comments below.

Important links:

Live app 👉 musiclovers.vercel.app
Repository 👉 gitlab/cockroach-serverless-with-next.js

This post took me a long time to write and create. If you enjoyed it, please be sure to give it a "❤" and follow me for similar posts.

With that being said, I'll conclude this by saying that serverless computing is amazing, and has a lot of potential. I'm planning to write another post sometime soon on when you should or shouldn't use a serverless database, so stay tuned and follow for more!

Enjoy your day, goodbye 👋!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK