Let's Build a Basic React App

Let's Build a Basic React App

React, Next.js, Next.js API Routes, Prisma, Vercel Postgres, NextAuth.js, TypeScript, Vercel, Git and GitHub

·

14 min read

This article is going to cover the basics of creating a React web application from beginning to end, including:

Much of this structure is pulled from https://vercel.com/guides/nextjs-prisma-postgres with some additional steps and some bug fixes that will get you up and running faster.

Create App and Production Pipeline

Creating a React App Locally

npx create-next-app --example https://github.com/prisma/blogr-nextjs-prisma/tree/main blogr-nextjs-prisma

If you do not yet have Node yet on your machine, you can download it at https://nodejs.org/en/download

Now you can run the React app locally with

cd blogr-nextjs-prisma 
npm run dev

And visit http://localhost:3000

Get Git Configured

In GitHub, create a new repo called blogr-nextjs-prisma.

Within the working directory (blogr-nextjs-prisma) run these commands

git init
git add .
git commit -m "initial commit" 
# replace xxx with your github username
git remote add origin https://github.com/xxx/blogr-nextjs-prisma.git
# you cannot use your password - need to use a generated access token https://github.com/settings/tokens
git push origin main

Connect GitHub and Vercel

Head over to https://vercel.com/new and import your newly created repo (blogr-nextjs-prisma) from GitHub.

This will wire it up so that when you push changes up to your GitHub repo, your app will automatically get built and deployed to Vercel.

Bring it Into Visual Studio Code

Open up VS Code, File > Open Folder and open up the blogr-nextjs-prisma directory. Go ahead and make a simple change (like adding a ! after the author's name on the Post.tsx file). Add your message and click Commit & Push.

You can verify that the build is in progress at https://vercel.com/ > blogr-nextjs-prisma > Deployments and then click on the deployment id (the 9 character id above the word Production)

Once it gets to Ready status, you can click the Visit button and verify that the site has been updated.

Power Up the PostgreSQL database

Set Up Database

In your Vercel project, select the Storage > Connect Database. Under the Create New tab, select Postgres and then the Continue button.

This causes some environment variables to be created, and we need to pull these down to our local environment. To do that, you need to have the Vercel CLI, which can be installed with the first line. The second line pulls the remote file down.

sudo npm i -g vercel@latest
vercel env pull .env

At this point, your local React app is pointing to the cloud-based Vercel database (both your local app and your remote app will point to the same remote database)

Fire Up Prisma

npm install prisma --save-dev

Create a folder called prisma and add a file called schema.prisma and paste the following code into that file:

// schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url = env("POSTGRES_PRISMA_URL") // uses connection pooling
  directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}

model Post {
  id        String     @default(cuid()) @id
  title     String
  content   String?
  published Boolean @default(false)
  author    User?   @relation(fields: [authorId], references: [id])
  authorId  String?
}

model User {
  id            String       @default(cuid()) @id
  name          String?
  email         String?   @unique
  createdAt     DateTime  @default(now()) @map(name: "created_at")
  updatedAt     DateTime  @updatedAt @map(name: "updated_at")
  posts         Post[]
  @@map(name: "users")
}

And then create the database and tables in Postgres using this command:

npx prisma db push
npm install @prisma/client
mkdir lib && touch lib/prisma.ts
npx prisma studio

This last line will open a web interface to your database. Make sure you add one post and one user (and relate them) so that you can see this in action.

You need to add this code to the prisma.ts file you just created.

import { PrismaClient } from '@prisma/client';

let prisma: PrismaClient;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }
  prisma = global.prisma;
}

export default prisma;

Now, whenever you need access to your database you can import the prisma instance into the file where it's needed.

Every time that you make a change to your Prisma schema, you need to update the Prisma Client with:

npx prisma generate

Now for Real Data

Update the Views

import prisma from '../lib/prisma';

Replace the getStaticProps within the pages/index.tsx file with the following code:

export const getStaticProps: GetStaticProps = async () => {
  const feed = await prisma.post.findMany({
    where: { published: true },
    include: {
      author: {
        select: { name: true },
      },
    },
  });
  return {
    props: { feed },
    revalidate: 10,
  };
};

You can learn more about Prisma at Prisma docs

Update the Details View

Edit /pages/p/[id] to include:

import prisma from '../../lib/prisma';

Now replace the getServerSideProps with the following:

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
  const post = await prisma.post.findUnique({
    where: {
      id: String(params?.id),
    },
    include: {
      author: {
        select: { name: true },
      },
    },
  });
  return {
    props: post,
  };
};

Test it out - see the beauty you have created.

npm run dev
# i had to update my react and react-dom in package.json 
# and then run npm i -force 
# error was 'Error: Next.js requires react >= 18.2.0 to be installed.'

To get this working in Vercel, I had to delete a couple of locked files, add "postinstall": "prisma generate" to the package.json > scripts section, and updated Typescript to npm install -D typescript@latest - not sure which of that combination worked.

Auth On

NextAuth Readiness

Install Next Auth

npm install next-auth@4 @next-auth/prisma-adapter

Update schema.prisma as required by NextAuth:

// schema.prisma

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url = env("POSTGRES_PRISMA_URL") // uses connection pooling
  directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
}

// schema.prisma

model Post {
  id        String  @id @default(cuid())
  title     String
  content   String?
  published Boolean @default(false)
  author    User?@relation(fields:[authorId], references:[id])
  authorId  String?
  }

model Account {
  id                 String  @id @default(cuid())
  userId             String  @map("user_id")
  type               String
  provider           String
  providerAccountId  String  @map("provider_account_id")
  refresh_token      String?
  access_token       String?
  expires_at         Int?
  token_type         String?
  scope              String?
  id_token           String?
  session_state      String?
  oauth_token_secret String?
  oauth_token        String?

  user User @relation(fields:[userId], references:[id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
  }

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique@map("session_token")
  userId       String   @map("user_id")
  expires      DateTime
  user         User     @relation(fields:[userId], references:[id], onDelete: Cascade)
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String?@unique
  emailVerified DateTime?
  image         String?
  posts         Post[]
  accounts      Account[]
  sessions      Session[]
}

model VerificationToken {
  id         Int      @id @default(autoincrement())
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

To get these changes into your database, run

npx prisma db push

Get GitHub App Configured

As described on OAuth app on GitHub, create a new GitHub OAuth app at https://github.com/settings/applications/new - using http://localhost:3000/api/auth as the Authorization callback URL (you will create another GitHub OAuth app later for production).

Update Your .env

On the newly created GitHub OAuth app, click on "Generate a new client secret". This will give you a Client Id and Client Secrets. Copy them into your .env file in your root directory (GITHUB_ID=Client Id & GITHUB_SECRET=Client Secrets).

# .env

# GitHub OAuth
GITHUB_ID=6bafeb321963449bdf51
GITHUB_SECRET=509298c32faa283f28679ad6de6f86b2472e1bff
NEXTAUTH_URL=http://localhost:3000/api/auth

Make Session Persistent Across App

In order to persist a user's authentication state across the entire application, wrap the root component with SessionProvider - which needs to be imported - as shown in _app.tsx:

import { SessionProvider } from 'next-auth/react';
import { AppProps } from 'next/app';

const App = ({ Component, pageProps }: AppProps) => {
  return (
    <SessionProvider session={pageProps.session}>
      <Component {...pageProps} />
    </SessionProvider>
  );
};

export default App;

changed to this page https://authjs.dev/getting-started/providers/oauth-tutorial

Add Log In Functionality

Update Header.tsx with the following:

import React from 'react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { signOut, useSession } from 'next-auth/react';

const Header: React.FC = () => {
  const router = useRouter();
  const isActive: (pathname: string) => boolean = (pathname) =>
    router.pathname === pathname;

  const { data: session, status } = useSession();

  let left = (
    <div className="left">
      <Link legacyBehavior href="/">
        <a className="bold" data-active={isActive('/')}>
          Feed
        </a>
      </Link>
      <style jsx>{`
        .bold {
          font-weight: bold;
        }

        a {
          text-decoration: none;
          color: var(--geist-foreground);
          display: inline-block;
        }

        .left a[data-active='true'] {
          color: gray;
        }

        a + a {
          margin-left: 1rem;
        }
      `}</style>
    </div>
  );

  let right = null;

  if (status === 'loading') {
    left = (
      <div className="left">
        <Link legacyBehavior href="/">
          <a className="bold" data-active={isActive('/')}>
            Feed
          </a>
        </Link>
        <style jsx>{`
          .bold {
            font-weight: bold;
          }

          a {
            text-decoration: none;
            color: var(--geist-foreground);
            display: inline-block;
          }

          .left a[data-active='true'] {
            color: gray;
          }

          a + a {
            margin-left: 1rem;
          }
        `}</style>
      </div>
    );
    right = (
      <div className="right">
        <p>Validating session ...</p>
        <style jsx>{`
          .right {
            margin-left: auto;
          }
        `}</style>
      </div>
    );
  }

  if (!session) {
    right = (
      <div className="right">
        <Link legacyBehavior href="/api/auth/signin">
          <a data-active={isActive('/signup')}>Log in</a>
        </Link>
        <style jsx>{`
          a {
            text-decoration: none;
            color: var(--geist-foreground);
            display: inline-block;
          }

          a + a {
            margin-left: 1rem;
          }

          .right {
            margin-left: auto;
          }

          .right a {
            border: 1px solid var(--geist-foreground);
            padding: 0.5rem 1rem;
            border-radius: 3px;
          }
        `}</style>
      </div>
    );
  }

  if (session) {
    left = (
      <div className="left">
        <Link legacyBehavior href="/">
          <a className="bold" data-active={isActive('/')}>
            Feed
          </a>
        </Link>
        <Link legacyBehavior href="/drafts">
          <a data-active={isActive('/drafts')}>My drafts</a>
        </Link>
        <style jsx>{`
          .bold {
            font-weight: bold;
          }

          a {
            text-decoration: none;
            color: var(--geist-foreground);
            display: inline-block;
          }

          .left a[data-active='true'] {
            color: gray;
          }

          a + a {
            margin-left: 1rem;
          }
        `}</style>
      </div>
    );
    right = (
      <div className="right">
        <p>
          {session.user.name} ({session.user.email})
        </p>
        <Link legacyBehavior href="/create">
          <button>
            <a>New post</a>
          </button>
        </Link>
        <button onClick={() => signOut()}>
          <a>Log out</a>
        </button>
        <style jsx>{`
          a {
            text-decoration: none;
            color: var(--geist-foreground);
            display: inline-block;
          }

          p {
            display: inline-block;
            font-size: 13px;
            padding-right: 1rem;
          }

          a + a {
            margin-left: 1rem;
          }

          .right {
            margin-left: auto;
          }

          .right a {
            border: 1px solid var(--geist-foreground);
            padding: 0.5rem 1rem;
            border-radius: 3px;
          }

          button {
            border: none;
          }
        `}</style>
      </div>
    );
  }

  return (
    <nav>
      {left}
      {right}
      <style jsx>{`
        nav {
          display: flex;
          padding: 2rem;
          align-items: center;
        }
      `}</style>
    </nav>
  );
};

export default Header;

Note that each Link has a legacyBehavior attribute added to it.

To check it out go

npm run dev

Create Login Page

mkdir -p pages/api/auth && touch pages/api/auth/[...nextauth].ts

Then add this to the newly created [...nextauth].ts file

import { NextApiHandler } from 'next';
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@next-auth/prisma-adapter';
import GitHubProvider from 'next-auth/providers/github';
import prisma from '../../../lib/prisma';

const authHandler: NextApiHandler = (req, res) => NextAuth(req, res, options);
export default authHandler;

const options = {
  providers: [
    GitHubProvider({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
  ],
  adapter: PrismaAdapter(prisma),
  secret: process.env.SECRET,
};

Create Content

Add a Post to Your Blog

Create the create page

touch pages/create.tsx

And then update the create page

import React, { useState } from 'react';
import Layout from '../components/Layout';
import Router from 'next/router';

const Draft: React.FC = () => {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');

  const submitData = async (e: React.SyntheticEvent) => {
    e.preventDefault();
    try {
      const body = { title, content };
      await fetch('/api/post', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(body),
      });
      await Router.push('/drafts');
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <Layout>
      <div>
        <form onSubmit={submitData}>
          <h1>New Draft</h1>
          <input
            autoFocus
            onChange={(e) => setTitle(e.target.value)}
            placeholder="Title"
            type="text"
            value={title}
          />
          <textarea
            cols={50}
            onChange={(e) => setContent(e.target.value)}
            placeholder="Content"
            rows={8}
            value={content}
          />
          <input disabled={!content || !title} type="submit" value="Create" />
          <a className="back" href="#" onClick={() => Router.push('/')}>
            or Cancel
          </a>
        </form>
      </div>
      <style jsx>{`
        .page {
          background: var(--geist-background);
          padding: 3rem;
          display: flex;
          justify-content: center;
          align-items: center;
        }

        input[type='text'],
        textarea {
          width: 100%;
          padding: 0.5rem;
          margin: 0.5rem 0;
          border-radius: 0.25rem;
          border: 0.125rem solid rgba(0, 0, 0, 0.2);
        }

        input[type='submit'] {
          background: #ececec;
          border: 0;
          padding: 1rem 2rem;
        }

        .back {
          margin-left: 1rem;
        }
      `}</style>
    </Layout>
  );
};

export default Draft;

Now we need to create a post route for handling the submission of the form.

mkdir -p pages/api/post && touch pages/api/post/index.ts

And here is the logic for making the magic of post happen.

import { getSession } from 'next-auth/react';
import prisma from '../../../lib/prisma';

// POST /api/post
// Required fields in body: title
// Optional fields in body: content
export default async function handle(req, res) {
  const { title, content } = req.body;

  const session = await getSession({ req });
  const result = await prisma.post.create({
    data: {
      title: title,
      content: content,
      author: { connect: { email: session?.user?.email } },
    },
  });
  res.json(result);
}

At this point, the drafts should created, but there is no drafts page. Time to go exploring!!

Debugging with Visual Studio Code

Click on the Run & Debug icon

You will get a requirement to create a launch.json file - do it. You also may need to install the .net SDK so that you can run your node instance locally https://dotnet.microsoft.com/en-us/download/dotnet/sdk-for-vs-code

The first problem I saw when debugging is this:

[next-auth][warn][NO_SECRET] https://next-auth.js.org/warnings#no_secret

Not the error I was looking to resolve, but worth addressing because this will cause a problem in production.

$ openssl rand -base64 32

Then paste that into your .env file as a new value for NEXTAUTH_SECRET

Bugs in the Tutorial

I spent a fair bit of time spinning my wheels on this. I was getting an error "argument connect of type userwhereuniqueinput needs at least one of id or email arguments." It looked like the session variable was not getting set. const session = await getSession({ req }) . I started poking around, and finally found some clues on the Prisma Slack page that pointed me to the most beautiful discussion https://github.com/prisma/prisma/discussions/19400 - thank you Daniel Ughelli for figuring this out!

You need to update your ...nextauth like this:

// pages/api/auth/[...nextauth].ts

import { NextApiHandler } from "next";
import NextAuth from "next-auth";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import GitHubProvider from "next-auth/providers/github";
import prisma from "../../../lib/prisma";

export const options = { // <--- exported the nextauth options
  providers: [
    GitHubProvider({
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_SECRET,
    }),
  ],
  adapter: PrismaAdapter(prisma),
  secret: process.env.SECRET,
};

const authHandler: NextApiHandler = (req, res) => NextAuth(req, res, options);
export default authHandler;

And your post API page like this:

// pages/api/post/index.ts
// import { getSession } from 'next-auth/react'; // <--- removed getSession import
import { getServerSession } from "next-auth/next" // <--- imported getServerSession from "next-auth/next"
import { options as authOptions } from "../auth/[...nextauth]" // <--- imported authOptions
import prisma from "../../../lib/prisma";

// POST /api/post
// Required fields in body: title
// Optional fields in body: content
export default async function handle(req, res) {
  const { title, content } = req.body;

  // const session = await getSession({ req }); // <--- removed getSession call
  const session = await getServerSession(req, res, authOptions); // <--- used the getServerSession instead
  const result = await prisma.post.create({
    data: {
      title: title,
      content: content,
      author: { connect: { email: session?.user?.email } },
    },
  });
  res.json(result);
}

Once I made these changes, my session was populated, and I immediately started posting drafts. Would be great if Prisma would update its walkthrough.

Creating the Drafts Page

touch pages/drafts.tsx

Now update the drafts.tsx page you just created with:

import React from 'react';
import { GetServerSideProps } from 'next';
import { useSession, getSession } from 'next-auth/react';
import Layout from '../components/Layout';
import Post, { PostProps } from '../components/Post';
import prisma from '../lib/prisma';

export const getServerSideProps: GetServerSideProps = async ({ req, res }) => {
  const session = await getSession({ req });
  if (!session) {
    res.statusCode = 403;
    return { props: { drafts: [] } };
  }

  const drafts = await prisma.post.findMany({
    where: {
      author: { email: session.user.email },
      published: false,
    },
    include: {
      author: {
        select: { name: true },
      },
    },
  });
  return {
    props: { drafts },
  };
};

type Props = {
  drafts: PostProps[];
};

const Drafts: React.FC<Props> = (props) => {
  const { data: session } = useSession();

  if (!session) {
    return (
      <Layout>
        <h1>My Drafts</h1>
        <div>You need to be authenticated to view this page.</div>
      </Layout>
    );
  }

  return (
    <Layout>
      <div className="page">
        <h1>My Drafts</h1>
        <main>
          {props.drafts.map((post) => (
            <div key={post.id} className="post">
              <Post post={post} />
            </div>
          ))}
        </main>
      </div>
      <style jsx>{`
        .post {
          background: var(--geist-background);
          transition: box-shadow 0.1s ease-in;
        }

        .post:hover {
          box-shadow: 1px 1px 3px #aaa;
        }

        .post + .post {
          margin-top: 2rem;
        }
      `}</style>
    </Layout>
  );
};

export default Drafts;

Push to Vercel

We are going to deviate from the tutorial here and just get this working on production.

Commit & push your changes, then create another GitHub OAuth app at https://github.com/settings/applications/new

Add your environment variables to your project on Vercel (under Settings)

Remember to include your secrets.

Remember that you need to redeploy after you add any environment variables to get them picked up.

Conclusion

You now have a pretty basic web app with authentication via NextAuth, hosting with Vercel, software pipelines with GitHub, ORM with Prisma, and data management with Vercel Postgres. Here is a link to the website frozen in time at this point of the development cycle https://blogr-nextjs-prisma-20atrk34i-dyor1.vercel.app/ and here is a link to the github repo https://github.com/dyor/blogr-nextjs-prisma/commit/a31a53ac8d196c0058519067641a6b48a45b2ec8

For my next exploration, I may either go deeper with this single app template, or I may dig in to Vercel's Platform starter kit https://vercel.com/new/templates/next.js/platforms-starter-kit that allows a builder to support multiple customers with multiple domains under one project. Or I may explore a totally different stack if I get a good recommendation.