Build a Twitter Clone Using TypeScript, Prisma and Next.js

Nirmalya Ghosh
Share

The best way to learn a tool like React is to build something with it. Next.js is a powerful framework that helps you build for production. In this tutorial, we’ll learn how to build a clone of Twitter using Next.js and Prisma.

Our app will have the following features:

  • authentication using NextAuth and Twitter OAuth
  • an option to add a new tweet
  • an option to view a list of tweets
  • an option to view the profile of a user with only their tweets

The code for the app we’ll be building is available on GitHub. We’ll be using TypeScript to build our app.

Preliminaries

Next.js is one of the most popular React.js frameworks. It has a lot of features like server-side rendering, TypeScript support, image optimization, I18n support, file-system routing, and more.

Prisma is an ORM for Node.js and TypeScript. It also provides a lot of features like raw database access, seamless relation API, native database types, and so on.

Software required

We’ll need the following installed for the purposes of running our app:

These technologies will be used in the app:

  • Next.js: for building our app
  • Prisma: for fetching and saving data into the database
  • Chakra UI: for adding styles to our app
  • NextAuth: for handling authentication
  • React Query: for fetching and updating data in our app

Creating a new Next.js App

Now, let’s get started! We’ll first create a new Next.js app by running the following command from our terminal:

yarn create next-app

We’ll need to enter the name of the app when the command prompts for it. We can name it anything we want. However, in this case, I’ll name it twitter-clone. We should be able to see a similar output on our terminal:

$ yarn create next-app

yarn create v1.22.5
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...

success Installed "create-next-app@10.0.4" with binaries:
      - create-next-app
✔ What is your project named? twitter-clone
Creating a new Next.js app in /twitter-clone.

....

Initialized a git repository.

Success! Created twitter-clone at /twitter-clone
Inside that directory, you can run several commands:

  yarn dev
    Starts the development server.

  yarn build
    Builds the app for production.

  yarn start
    Runs the built app in production mode.

We suggest that you begin by typing:

  cd twitter-clone
  yarn dev

We can now go inside the twitter-clone directory and start our app by running the following command:

cd twitter-clone && yarn dev

Our Next.js app should be up and running on http://localhost:3000. We should be able to see the following screen:

Next.js app running on localhost:3000

Adding a Dockerized PostgreSQL Database

Next, let’s add a Dockerized PostgreSQL database so that we can save the users and tweets into it. We can create a new docker-compose.yml file in the root of our app with the following content:

version: "3"

services:
  db:
    container_name: db
    image: postgres:11.3-alpine
    ports:
      - "5432:5432"
    volumes:
      - db_data:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  db_data:

If Docker is running on our machine, we can execute the following command from the root of our app to start our PostgreSQL container:

docker-compose up

The above command will start the PostgreSQL container and it can be accessed on postgresql://postgres:@localhost:5432/postgres. Note that you can also use a local installation of Postgres instead of a Dockerized one.

Adding Chakra UI

Chakra UI is a very simple React.js component library. It’s very popular and has the features like accessibility, support for both light and dark mode, and more. We’ll be using Chakra UI for styling our user interface. We can install that package by running the following command from the root of our app:

yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion

Let’s rename our _app.js file to _app.tsx inside the pages directory and replace its content with the following:

// pages/_app.tsx

import { ChakraProvider } from "@chakra-ui/react";
import { AppProps } from "next/app";
import Head from "next/head";
import React from "react";

const App = ({ Component, pageProps }: AppProps) => {
  return (
    <>
      <Head>
        <link rel="shortcut icon" href="/images/favicon.ico" />
      </Head>
      <ChakraProvider>
        <Component {...pageProps} />
      </ChakraProvider>
    </>
  );
};

export default App;

Since we added a new TypeScript file, we’ll need to restart our Next.js server. Once we restart our server, we’ll get the following error:

$ yarn dev

yarn run v1.22.5
$ next dev
ready - started server on http://localhost:3000
It looks like you're trying to use TypeScript but do not have the required package(s) installed.

Please install typescript, @types/react, and @types/node by running:

  yarn add --dev typescript @types/react @types/node

If you are not trying to use TypeScript, please remove the tsconfig.json file from your package root (and any TypeScript files in your pages directory).

This is because we added a new TypeScript file but didn’t add the necessary dependencies that are required to run them. We can fix that by installing the missing dependencies. From the root of our app, we can execute the following command to install the missing dependencies:

yarn add --dev typescript @types/react @types/node

Now, if we start our Next.js server, our app should compile:

$ yarn dev

yarn run v1.22.5
$ next dev
ready - started server on http://localhost:3000
We detected TypeScript in your project and created a tsconfig.json file for you.

event - compiled successfully

Adding NextAuth

NextAuth is an authentication library for Next.js. It’s simple and easy to understand, flexible and secure by default. To set up NextAuth in our app, we’ll need to install it by running the following command from the root of our app:

yarn add next-auth

Next, we’ll have to update our pages/_app.tsx file with the following content:

// pages/_app.tsx

import { ChakraProvider } from "@chakra-ui/react";
import { Provider as NextAuthProvider } from "next-auth/client";
import { AppProps } from "next/app";
import Head from "next/head";
import React from "react";

const App = ({ Component, pageProps }: AppProps) => {
  return (
    <>
      <Head>
        <link rel="shortcut icon" href="/images/favicon.ico" />
      </Head>
      <NextAuthProvider session={pageProps.session}>
        <ChakraProvider>
          <Component {...pageProps} />
        </ChakraProvider>
      </NextAuthProvider>
    </>
  );
};

export default App;

Here, we’re wrapping our app with NextAuthProvider. Next, we’ll have to create a new file named [...nextauth].ts inside the pages/api/auth directory with the following content:

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

import { NextApiRequest, NextApiResponse } from "next";
import NextAuth from "next-auth";
import Providers from "next-auth/providers";

const options = {
  providers: [
    Providers.Twitter({
      clientId: process.env.TWITTER_KEY,
      clientSecret: process.env.TWITTER_SECRET,
    }),
  ],
};

export default NextAuth(options);

The above file will be responsible for handling our authentication using Next.js API routes. Next, we’ll create a new filed named .env in the root of our app to store all our environment variables with the following content:

DATABASE_URL="postgresql://postgres:@localhost:5432/postgres?synchronize=true"
NEXTAUTH_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000
TWITTER_KEY=""
TWITTER_SECRET=""

The Twitter environment variables will be generated from the Twitter API. We’ll be doing that next. We can create a new Twitter app from the Twitter Developer dashboard.

  1. Create a new Twitter app by entering its name and click on the Complete button.

    Create a new Twitter app

  2. Copy the API key, API secret key and Bearer token in the next screen.

    The credentials of our Twitter app

  3. Change the App permissions from Read Only to Read and Write in the next screen.

    Twitter app permissions

  4. Click on the Edit button next to the Authentication settings to enable 3-legged OAuth.

    Authentication settings for our Twitter app

  5. Enable 3-legged OAuth and Request email address from users and add http://localhost:3000/api/auth/callback/twitter as a Callback URL.

    Edit the authentication settings of our Twitter app

  6. The Website URL, Terms of service and Privacy policy files can be anything (such as https://yourwebsite.com, https://yourwebsite.com/terms and https://yourwebsite.com/privacy respectively).

Our 3-legged OAuth should be enabled now.

Enable the 3-legged OAuth of our Twitter app

Paste the value of the API key from Step 2 into the TWITTER_KEY environment variable and the value of API secret key into the TWITTER_SECRET environment variable.

Our .env file should look like this now:

DATABASE_URL="postgresql://postgres:@localhost:5432/postgres"
NEXTAUTH_URL=http://localhost:3000
NEXT_PUBLIC_API_URL=http://localhost:3000
TWITTER_KEY="1234" // Replace this with your own API key
TWITTER_SECRET="secret" // Replaces this with your own API secret key

Now, if we restart our Next.js server and visit http://localhost:3000/api/auth/signin, we should be able to see the Sign in with Twitter button:

Sign in with Twitter button

If we click on that button, we’ll be able to authorize our Twitter app but we won’t be able to log in to our app. Our terminal will show the following error:

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

We’ll fix this issue next when we’ll be adding and configuring Prisma.

Adding and Configuring Prisma

First, we need to install all the necessary dependencies. We can do that by running the following command from the root of our app:

yarn add prisma @prisma/client

Next, let’s create a new file named prisma.ts inside the lib/clients directory with the following content:

// lib/clients/prisma.ts

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

const prisma = new PrismaClient();

export default prisma;

This PrismaClient will be re-used across multiple files. Next, we’ll have to update our pages/api/auth/[...nextauth].ts file with the following content:

....

import prisma from "../../../lib/clients/prisma";
import Adapters from "next-auth/adapters";

....

const options = {
  providers: [
    ....
  ],
  adapter: Adapters.Prisma.Adapter({ prisma }),
};

....

Now, if we visit http://localhost:3000/api/auth/signin, we’ll get the following error on our terminal:

Error: @prisma/client did not initialize yet. Please run "prisma generate" and try to import it again.

To fix this issue, we’ll have to do the following:

  1. Run npx prisma init from the root of our app:
   $ npx prisma init

   Environment variables loaded from .env

   ✔ Your Prisma schema was created at prisma/schema.prisma.
     You can now open it in your favorite editor.

   warn Prisma would have added DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" but it already exists in .env

   Next steps:
   1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started.
   2. Set the provider of the datasource block in schema.prisma to match your database: postgresql, mysql or sqlite.
   3. Run prisma introspect to turn your database schema into a Prisma data model.
   4. Run prisma generate to install Prisma Client. You can then start querying your database.

   More information in our documentation:
   https://pris.ly/d/getting-started
  1. Run npx prisma generate from the root of our app:
   $ npx prisma generate
                               4s
   Environment variables loaded from .env
   Prisma schema loaded from prisma/schema.prisma
   Error:
   You don't have any models defined in your schema.prisma, so nothing will be generated.
   You can define a model like this:

   model User {
     id    Int     @id @default(autoincrement())
     email String  @unique
     name  String?
   }

   More information in our documentation:
   https://pris.ly/d/prisma-schema
  1. Update the prisma/schema.prisma file with the schema that NextAuth expects:
   // prisma/schema.prisma

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

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

   model Account {
     id                 Int       @id @default(autoincrement())
     compoundId         String    @unique @map("compound_id")
     userId             Int       @map("user_id")
     providerType       String    @map("provider_type")
     providerId         String    @map("provider_id")
     providerAccountId  String    @map("provider_account_id")
     refreshToken       String?   @map("refresh_token")
     accessToken        String?   @map("access_token")
     accessTokenExpires DateTime? @map("access_token_expires")
     createdAt          DateTime  @default(now()) @map("created_at")
     updatedAt          DateTime  @default(now()) @map("updated_at")

     @@index([providerAccountId], name: "providerAccountId")
     @@index([providerId], name: "providerId")
     @@index([userId], name: "userId")
     @@map("accounts")
   }

   model Session {
     id           Int      @id @default(autoincrement())
     userId       Int      @map("user_id")
     expires      DateTime
     sessionToken String   @unique @map("session_token")
     accessToken  String   @unique @map("access_token")
     createdAt    DateTime @default(now()) @map("created_at")
     updatedAt    DateTime @default(now()) @map("updated_at")

     @@map("sessions")
   }

   model User {
     id            Int       @id @default(autoincrement())
     name          String?
     email         String?   @unique
     emailVerified DateTime? @map("email_verified")
     image         String?
     createdAt     DateTime  @default(now()) @map("created_at")
     updatedAt     DateTime  @default(now()) @map("updated_at")
     tweets        Tweet[]

     @@map("users")
   }

   model VerificationRequest {
     id         Int      @id @default(autoincrement())
     identifier String
     token      String   @unique
     expires    DateTime
     createdAt  DateTime @default(now()) @map("created_at")
     updatedAt  DateTime @default(now()) @map("updated_at")

     @@map("verification_requests")
   }
  1. Add the schema for Tweet in the prisma/schema.prisma file:
   // prisma/schema.prisma

   ....

   model Tweet {
     id        Int      @id @default(autoincrement())
     body      String
     userId    Int
     createdAt DateTime @default(now()) @map("created_at")
     updatedAt DateTime @default(now()) @map("updated_at")
     author    User     @relation(fields: [userId], references: [id])

     @@map("tweets")
   }
  1. Run npx prisma migrate dev --preview-feature from the root of our app to create a new migration. Enter the name of the migration (such as init-database) when prompted.

Now, if we visit http://localhost:3000/api/auth/signin and click on the Sign in with Twitter button, we’ll be logged in to our app using Twitter.

Adding Some Seed Data

So that the UI isn’t completely bare as we work on the app, let’s add some seed data.

Let’s start off by installing a couple of dependencies:

yarn add -D faker ts-node

This pulls in faker.js, which will aid us in generating fake data, as well as its ts-node dependency.

Next, create a new seed.ts file in the prisma folder, and add the following content:

import faker from "faker";
import prisma from "../lib/clients/prisma";

async function main() {
  const listOfNewUsers = [...new Array(5)].map(() => {
    return {
      email: faker.internet.email(),
      name: faker.name.findName(),
      image: faker.image.image(),
      tweets: {
        create: {
          body: faker.lorem.sentence(),
        },
      },
    };
  });

  for (let data of listOfNewUsers) {
    const user = await prisma.user.create({
      data,
    });

    console.log(user);
  }
}

main()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

We’ll also need to update our tsconfig.json file, as shown:

{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": false,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "baseUrl": ".",
    "paths": {
      "*": [
        "/*"
      ],
      "components/*": [
        "components/*"
      ],
      "pages/*": [
        "pages/*"
      ],
      "types/*": [
        "types/*"
      ],
      "lib/*": [
        "lib/*"
      ],
    },
  },
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx"
  ],
  "exclude": [
    "node_modules"
  ]
}

Finally, we can run npx prisma db seed --preview-feature to seed our database with some test data.

Adding React Query

React Query is a very popular and performant way of fetching data in React.js apps. Let’s add React Query to our app. We can install React Query by running the following command from the root of our app:

yarn add react-query

Next, let’s create a new file named react-query.ts inside the lib/clients directory with the following content:

// lib/clients/react-query.ts

import { QueryClient } from "react-query";

const queryClient = new QueryClient();

export default queryClient;

We’ll also need to update our pages/_app.tsx file with the following content:

// pages/_app.tsx

....

import { QueryClientProvider } from "react-query";
import { Hydrate } from "react-query/hydration";
import queryClient from "../lib/clients/react-query";

const App = ({ Component, pageProps }: AppProps) => {
  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Head>
          <link rel="shortcut icon" href="/images/favicon.ico" />
        </Head>
        <NextAuthProvider session={pageProps.session}>
          <ChakraProvider>
            <Component {...pageProps} />
          </ChakraProvider>
        </NextAuthProvider>
      </Hydrate>
    </QueryClientProvider>
  );
};

export default App;

Here, we’re wrapping our app with QueryClientProvider, which will provide a QueryClient to our app.

Option to View a List of Tweets

Let’s create a new file called fetch-tweets.ts inside the lib/queries directory, with the following content:

// lib/queries/fetch-tweets.ts

const fetchTweets = async () => {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/tweets`);
  const data = await res.json();

  return data;
};

export default fetchTweets;

This function will be responsible for fetching all the tweets in our app. Next, create a new file called tweets.tsx inside the pages directory with the following content:

// pages/tweets.tsx

import fetchTweets from "../lib/queries/fetch-tweets";
import queryClient from "../lib/clients/react-query";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/client";
import Head from "next/head";
import React from "react";
import { useQuery } from "react-query";
import { dehydrate } from "react-query/hydration";

const TweetsPage: InferGetServerSidePropsType<
  typeof getServerSideProps
> = ({}) => {
  const { data } = useQuery("tweets", fetchTweets);
  const [session] = useSession();

  if (!session) {
    return <div>Not authenticated.</div>;
  }

  return (
    <>
      <Head>
        <title>All tweets</title>
      </Head>
      {console.log(JSON.stringify(data, null, 2))}
    </>
  );
};

export const getServerSideProps: GetServerSideProps = async ({ req }) => {
  await queryClient.prefetchQuery("tweets", fetchTweets);

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};

export default TweetsPage;

getServerSideProps is a Next.js function that helps in fetching data on the server. Let’s also create a new file named index.ts inside the pages/api/tweets directory with the following content:

// pages/api/tweets/index.ts

import prisma from "../../../lib/clients/prisma";
import type { NextApiRequest, NextApiResponse } from "next";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === "POST") {
    try {
      const { body } = req;
      const tweet = await prisma.tweet.create({ data: JSON.parse(body) });

      return res.status(200).json(tweet);
    } catch (error) {
      return res.status(422).json(error);
    }
  } else if (req.method === "GET") {
    try {
      const tweets = await prisma.tweet.findMany({
        include: {
          author: true,
        },
        orderBy: [
          {
            createdAt: "desc",
          },
        ],
      });

      return res.status(200).json(tweets);
    } catch (error) {
      return res.status(422).json(error);
    }
  }

  res.end();
};

Here, we’re checking the request. If it’s a POST request, we’re creating a new tweet. If it’s a GET request, we’re sending all the tweets with the details of author. Now, if we visit http://localhost:3000/tweets, we’ll view all the tweets in our browser’s console.

List of tweets from the API endpoint

Note that, as faker.js generates random data, what you see logged to your browser’s console will vary from the screenshot. We’ll add the option to add a tweet later.

Next, let’s build the user interface for showing the list of tweets. We can create a new file named index.tsx inside the components/pages/tweets directory with the following content:

// components/pages/tweets/index.tsx

import { Box, Grid, Stack } from "@chakra-ui/react";
import Tweet from "./tweet";
import React from "react";
import ITweet from "types/tweet";

const TweetsPageComponent = ({ tweets }) => {
  return (
    <Stack spacing={8}>
      <Grid templateColumns={["1fr", "1fr", "repeat(2, 1fr)"]} gap={8}>
        {tweets?.map((tweet: ITweet) => {
          return (
            <Box key={tweet.id}>
              <Tweet tweet={tweet} />
            </Box>
          );
        })}
      </Grid>
    </Stack>
  );
};

export default TweetsPageComponent;

Let’s also create a new file named tweet.tsx inside the same directory (components/pages/tweets) with the following content:

// components/pages/tweets/tweet.tsx

import { Avatar, Box, Stack, Text } from "@chakra-ui/react";
import React, { FC } from "react";

const Tweet: FC = ({ tweet }) => {
  const authorNode = () => {
    return (
      <Stack
        spacing={4}
        isInline
        alignItems="center"
        p={4}
        borderBottomWidth={1}
      >
        <Avatar name={tweet.author.name} src={tweet.author.image} />
        <Stack>
          <Text fontWeight="bold">{tweet.author.name}</Text>
        </Stack>
      </Stack>
    );
  };

  const bodyNode = () => {
    return (
      <Text fontSize="md" p={4}>
        {tweet.body}
      </Text>
    );
  };

  return (
    <Box shadow="lg" rounded="lg">
      <Stack spacing={0}>
        {authorNode()}
        {bodyNode()}
      </Stack>
    </Box>
  );
};

export default Tweet;

Next, let’s update our pages/tweets.tsx file with the following content:

// pages/tweets.tsx

....

import Page from "../components/pages/tweets";

....

const TweetsPage: InferGetServerSidePropsType<
  typeof getServerSideProps
> = ({}) => {

....

  return (
    <>
      <Head>
        <title>All tweets</title>
      </Head>
      <Page tweets={data} />
    </>
  );

....

}

....

Here, we’ve modified the interface of our app. Now, if we visit http://localhost:3000/tweets, we should be able to see the following:

List of tweets

Option to Add a New Tweet

Let’s add a text area through which we can add a new tweet. To do that, let’s create a new file named add-new-tweet-form.tsx inside the components/pages/tweets directory with the following content:

// components/pages/tweets/add-new-tweet-form.tsx

import {
  Box,
  Button,
  FormControl,
  FormLabel,
  Stack,
  Textarea,
} from "@chakra-ui/react";
import saveTweet from "../../../lib/mutations/save-tweet";
import fetchTweets from "../../../lib/queries/fetch-tweets";
import queryClient from "../../../lib/clients/react-query";
import { useSession } from "next-auth/client";
import React, { ChangeEvent, useState } from "react";
import { useMutation, useQuery } from "react-query";

const AddNewTweetForm = () => {
  const [body, setBody] = useState("");
  const [session] = useSession();
  const { refetch } = useQuery("tweets", fetchTweets);
  const mutation = useMutation(saveTweet, {
    onSuccess: async () => {
      await queryClient.invalidateQueries("tweets");

      refetch();
    },
  });

  if (!session) {
    return <div>Not authenticated.</div>;
  }

  const handleSubmit = () => {
    const data = {
      body,
      author: {
        connect: { email: session.user.email },
      },
    };

    mutation.mutate(data);

    if (!mutation.error) {
      setBody("");
    }
  };

  return (
    <Stack spacing={4}>
      <Box p={4} shadow="lg" rounded="lg">
        <Stack spacing={4}>
          <FormControl isRequired>
            <FormLabel htmlFor="body">What's on your mind?</FormLabel>
            <Textarea
              id="body"
              value={body}
              onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
                setBody(e.currentTarget.value)
              }
            />
          </FormControl>
          <FormControl>
            <Button
              loadingText="Posting..."
              onClick={handleSubmit}
              isDisabled={!body.trim()}
            >
              Post
            </Button>
          </FormControl>
        </Stack>
      </Box>
    </Stack>
  );
};

export default AddNewTweetForm;

The mutation function is responsible for doing the POST request to the server. It also re-fetches the data once the request is successful. Also, let’s create a new file named save-tweet.ts inside the lib/mutations directory with the following content:

// lib/mutations/save-tweet.ts

const saveTweet = async (body: any) => {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/tweets`, {
    method: "POST",
    body: JSON.stringify(body),
  });
  const data = await res.json();

  return data;
};

export default saveTweet;

We also need to modify our components/pages/tweets/index.tsx file with following content:

// components/pages/tweets/index.tsx

....

import AddNewTweetForm from "./add-new-tweet-form";

....

const TweetsPageComponent = ({ tweets }) => {
  return (
    <Stack spacing={8}>
      <Box>
        <AddNewTweetForm />
      </Box>

      ....

    </Stack>
  );
};

export default TweetsPageComponent;

Now, we should be able to view a textarea if we visit http://localhost:3000/tweets:

Textarea to add new tweets

We should also be able to add a new tweet using the textarea (this won’t tweet to your actual account!):

Add a new tweet

Next, we’ll add the option to view the profile of a user which shows only the tweets posted by that user.

Option to View the Profile of a User with only Their Tweets

First, we’ll create a page that will show a list of all the users. To do that, we’ll need to create a new file named index.tsx inside the pages/users directory with the following content:

// pages/users/index.tsx

import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { useSession } from "next-auth/client";
import Head from "next/head";
import React from "react";
import { useQuery } from "react-query";
import { dehydrate } from "react-query/hydration";
import Page from "../../components/pages/users";
import queryClient from "../../lib/clients/react-query";
import fetchUsers from "../../lib/queries/fetch-users";

const MyAccountPage: InferGetServerSidePropsType<
  typeof getServerSideProps
> = ({}) => {
  const { data } = useQuery("users", fetchUsers);
  const [session] = useSession();

  if (!session) {
    return <div>Not authenticated.</div>;
  }

  return (
    <>
      <Head>
        <title>All users</title>
      </Head>
      <Page users={data} />
    </>
  );
};

export const getServerSideProps: GetServerSideProps = async ({ req }) => {
  await queryClient.prefetchQuery("users", fetchUsers);

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};

export default MyAccountPage;

We’ll also need to create a new file named fetch-users.ts inside the lib/queries directory with the following content:

// lib/queries/fetch-users.ts

const fetchUsers = async () => {
  const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/users`);
  const data = await res.json();

  return data;
};

export default fetchUsers;

This function will be responsible for fetching all the users from the API endpoint. We’ll also need to create a new file named index.tsx inside the components/pages/users directory with the following content:

// components/pages/users/index.tsx

import { Box, Grid, Stack } from "@chakra-ui/react";
import React from "react";
import User from "./user";

const UsersPageComponent = ({ users }) => {
  return (
    <Stack spacing={8}>
      <Grid templateColumns={["1fr", "1fr", "repeat(2, 1fr)"]} gap={8}>
        {users?.map((user) => {
          return (
            <Box key={user.id}>
              <User user={user} />
            </Box>
          );
        })}
      </Grid>
    </Stack>
  );
};

export default UsersPageComponent;

Next, let’s create a file named user.tsx inside the same directory (components/pages/users) with the following content:

// components/pages/users/user.tsx

import { Avatar, Box, Stack, Text, Button } from "@chakra-ui/react";
import Link from "next/link";
import React, { FC } from "react";

const User: FC = ({ user }) => {
  const authorNode = () => {
    return (
      <Stack
        spacing={4}
        isInline
        alignItems="center"
        p={4}
        borderBottomWidth={1}
      >
        <Avatar name={user.name} src={user.image} />
        <Stack>
          <Text fontWeight="bold">{user.name}</Text>
        </Stack>
      </Stack>
    );
  };

  const bodyNode = () => {
    return (
      <Text fontSize="md" p={4}>
        {user.email}
      </Text>
    );
  };

  const buttonNode = () => {
    return (
      <Box p={4} borderTopWidth={1}>
        <Link href={`/users/${user.id}`}>
          <Button>View profile</Button>
        </Link>
      </Box>
    );
  };

  return (
    <Box shadow="lg" rounded="lg">
      <Stack spacing={0}>
        {authorNode()}
        {bodyNode()}
        {buttonNode()}
      </Stack>
    </Box>
  );
};

export default User;

And one more file named index.ts inside the pages/api/users directory with the following content:

// pages/api/users/index.ts

import prisma from "../../../lib/clients/prisma";
import type { NextApiRequest, NextApiResponse } from "next";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === "GET") {
    try {
      const users = await prisma.user.findMany({
        orderBy: [
          {
            createdAt: "desc",
          },
        ],
      });

      return res.status(200).json(users);
    } catch (error) {
      return res.status(422).json(error);
    }
  }

  res.end();
};

The above function is responsible for sending the details of all the users. Now, if we visit http://localhost:3000/users, we should be able to see a list of users:

List of users

Now, let’s create the page to show the details for a single user. To do that, we’ll need to create a new file named [id].tsx inside the pages/users directory with the following content:

// pages/users/[id].tsx

import Page from "../../components/pages/users/[id]";
import queryClient from "../../lib/clients/react-query";
import fetchUser from "../../lib/queries/fetch-user";
import { GetServerSideProps, InferGetServerSidePropsType } from "next";
import { getSession, useSession } from "next-auth/client";
import Head from "next/head";
import React from "react";
import { useQuery } from "react-query";
import { dehydrate } from "react-query/hydration";

const MyAccountPage: InferGetServerSidePropsType<typeof getServerSideProps> = ({
  id,
}) => {
  const { data } = useQuery("user", () => fetchUser(parseInt(id as string)));
  const [session] = useSession();

  if (!session) {
    return <div>Not authenticated.</div>;
  }

  return (
    <>
      <Head>
        <title>{session.user.name}'s profile</title>
      </Head>
      <Page user={data} />
    </>
  );
};

export const getServerSideProps: GetServerSideProps = async ({ query }) => {
  await queryClient.prefetchQuery("user", () =>
    fetchUser(parseInt(query.id as string))
  );

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
      id: query.id,
    },
  };
};

export default MyAccountPage;

The value of query.id determines the id of the current user. We’ll also need to create a new file named fetch-user.ts inside the lib/queries directory with the following content:

// lib/queries/fetch-user.ts

const fetchUser = async (userId: number) => {
  const res = await fetch(
    `${process.env.NEXT_PUBLIC_API_URL}/api/users/${userId}`
  );
  const data = await res.json();

  return data;
};

export default fetchUser;

The above function will be responsible for doing the GET request to the API endpoint. Next, we’ll need to create a new file named index.tsx inside the components/pages/users/[id] directory with the following content:

// components/pages/users/[id]/index.tsx

import { Avatar, Box, Grid, Stack, Text } from "@chakra-ui/react";
import Tweet from "./tweet";
import React, { FC } from "react";

const UsersPageComponent: FC = ({ user }) => {
  const authorNode = () => {
    return (
      <Stack spacing={4} isInline alignItems="center">
        <Avatar name={user?.name} src={user?.image} />
        <Stack>
          <Text fontWeight="bold" fontSize="4xl">
            {user?.name}
          </Text>
        </Stack>
      </Stack>
    );
  };

  return (
    <Stack spacing={8}>
      {authorNode()}
      <Grid templateColumns={["1fr", "1fr", "repeat(2, 1fr)"]} gap={8}>
        {user?.tweets.map((tweet) => {
          return (
            <Box key={tweet.id}>
              <Tweet tweet={tweet} />
            </Box>
          );
        })}
      </Grid>
    </Stack>
  );
};

export default UsersPageComponent;

Next, we’ll need to create one more file named tweet.tsx inside the same directory (components/pages/users/[id]) with the following content:

// components/pages/users/[id]/tweet.tsx

import { Box, Stack, Text } from "@chakra-ui/react";
import React, { FC } from "react";

const Tweet: FC = ({ tweet }) => {
  const bodyNode = () => {
    return (
      <Text fontSize="md" p={4}>
        {tweet.body}
      </Text>
    );
  };

  return (
    <Box shadow="lg" rounded="lg">
      <Stack spacing={0}>{bodyNode()}</Stack>
    </Box>
  );
};

export default Tweet;

Finally, we’ll need to create one more file named [id].ts inside the pages/api/users directory with the following content:

// pages/api/users/[id].ts

import prisma from "../../../lib/clients/prisma";
import type { NextApiRequest, NextApiResponse } from "next";

export default async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === "GET") {
    const userId = parseInt(req.query.id as string);

    try {
      const tweets = await prisma.user.findUnique({
        include: {
          tweets: true,
        },
        where: {
          id: userId,
        },
      });

      return res.status(200).json(tweets);
    } catch (error) {
      console.log(error);

      return res.status(422).json(error);
    }
  }

  res.end();
};

The above function will be responsible for sending the details of the user whose id is the same as req.query.id. We’re converting it to a number, as Prisma requires it to be numeric. Now, if we visit http://localhost:3000/users and click on the View profile button for a user, we’ll be able to see a list of tweets posted by that user.

Profile of a user with all the tweets posted by that user

Conclusion

In this tutorial, we’ve learned how we can use Next.js and Prisma together to build a clone of Twitter. Obviously, Twitter consists of a lot of other features like retweet, comment and sharing functionalities for each tweet. However, this tutorial should provide the base for building such features.

The code for the app we built is available on GitHub. Feel free to check it out. You can also check out a live demo of the app we’ve been building here.