Building a Fullstack Twitter Clone with NextJS and Prisma

Kunal Shah

Kunal Shah / May 29, 2020

21 min read

This tutorial covers how to build a fullstack application that allows users to sign up or login, then post tweets to a global feed. You can find the code for the completed app here.

A demo of what we'll be building is currently deployed at fullstack-twitter.onrender.com

Finished Screenshot

Prerequisites

Before we get started, make sure you have node and yarn installed.

Getting Started

First, create a new npm project

mkdir fullstack-twitter-clone
cd fullstack-twitter-clone
npm init -y

Now, add dependencies for next.js and react, as well as some typed development dependencies

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

Now, we create the designated pages directory that next.js uses for file-based routing.

mkdir pages

Every file within the pages directory is compiled into it's own route, so index.tsx can be visited at /, about.tsx at /about, and so on.

Lets add the following component to our first page, index.tsx.

pages/index.tsx
export default () => <div> hello, world! </div>;

Now, run the Nextjs development server

npx next

and visit http://localhost:3000 to see our first component in action. We should have a barebones unstyled webpage with "hello, world!" in the top left.

The backend

Now that our react code has the client up and running, let's use Next.js's API routes to write a backend handler in the designated api directory within pages

mkdir pages/api

Create a file, feed.ts within the api directory and create a simple function that returns an array.

pages/api/feed.ts
export default (req, res) => res.json({ feed: [] });

Head to http://localhost:3000/api/feed and you should see some json within your browser.

{
  "feed": []
}

Let's make our feed more interesting by adding some fake tweets to feed.ts

pages/api/feed.ts
export default (req, res) => {
  const feed = [
    {
      text:
        'Wow not having to configure and transpile typescript is one of the best parts of next.js',
      author: { username: 'john' }
    },
    {
      text:
        "I'm a firm believer that dark mode should be a universal default on the web",
      author: { username: 'jill' }
    }
  ];
  res.json(feed);
};

Bonus: you can take a sneak peek at the feed endpoint of the production app, which our endpoint will eventually build up to, at fullstack-twitter.onrender.com/api/feed

Visit your browser again you should see the fake tweets being rendered as raw json.

Feed JSON preview

Note: I have a browser extension, JSON formatter, installed that prettifies raw JSON, like in the screenshot above.

Put the two together

The real power with this approach is that we can write frontend and backend code in the same place, in the same language, and split the logic accordingly. All of the source code goes into the pages directory, and the backend code is limited to the api directory. Each seperate file, whether a frontend page or a backend route, is compiled into it's own endpoint, and the two work together to power a fullstack application.

To pull them together, we query the new api/feed endpoint from the pages/index.tsx page, and show our list of tweets to the user. We're going to user a small library called SWR for our data fetching, which handles caching, locally changing data during POST requests, and revalidation. The power of SWR and it's ability to make handling cached data on the frontend will soon become obvious.

Also, we want our app to be beautiful on more than just the inside, so let's use Ant Design to boostrap our interface's styles.

First, we install both libraries

yarn add swr antd

Create a top-level components directory, and a util directory within that. Inside util, create fetcher.tsx and hooks.tsx.

Within fetcher.tsx we have

components/util/fetcher.tsx
export const fetcher = (url, data = undefined) =>
  fetch(window.location.origin + url, {
    method: data ? 'POST' : 'GET',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(data)
  }).then((r) => r.json());

This basically abstracts away the complexity of POST and GET requests when using SWR, so the requests within the components themselves won't clutter up our react code.

In hooks.tsx we add

components/util/hooks.tsx
import useSWR from 'swr';
import { fetcher } from './fetcher';

export function useFeed() {
  const { data: feed } = useSWR('/api/feed', fetcher);
  return { feed };
}

Finally, let's pull this all together in components/Feed.tsx, rendering each Tweet in Ant Design's Card component

components/Feed.tsx
import { Card } from 'antd';
import { useFeed } from './util/hooks';

export const Feed = () => {
  const { feed } = useFeed();
  return feed ? (
    <>
      {feed.map(({ id, text, author }, i) => (
        <Card key={i}>
          <h4>{text}</h4>
          <span>{author.username}</span>
        </Card>
      ))}
    </>
  ) : null;
};

which will give us the same contents as the value of the json endpoint, demonstrating that the data is being retrieved correctly. Finally, we can render the feed in pages/index.tsx ah

pages/index.tsx
import { Col, Row } from 'antd';
import { Feed } from '../components/Feed';

export default () => (
  <Row>
    <Col md={{ span: 10, offset: 8 }}>
      <Feed />
    </Col>
  </Row>
);

For one last detail, we need to import Ant Design's CSS stylesheet into our app, so that it's automatically included in all our pages. We do this with a special file, _app.js in the pages directory, which next.js uses to wrap all of the other pages.

pages/_app.js
import 'antd/dist/antd.css';

export default function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />;
}

Note: you'll have to restart your development server for changes to _app.js to take effect.

Now visit http://localhost:3000 and we'll see the naked data from our backend being rendered

Card Tweets with Ant Design preview

Creating new tweets

Our twitter app won't work if all users can do is read tweets, so we need to give them a way to create them too. Let's add a form component that users can useto add new tweets. Inside components create CreateTweetForm.tsx.

Notice the naming conventions, which are entirely for the sake of organization and can be changed to your liking:

  • Components are capitalized TSX files (Feed.tsx)
  • Pages are lowercased TSX files (index.tsx)
  • API routes are lowercased TS files (feed.ts)

In CreateTweetForm.tsx we call the same useFeed() hook as in Feed.tsx, and we additionally make use of the mutate export from swr. This allows us to change the local state of our feed to reflect the change, even before it's registered by the server, so the user can see their new tweet right away.

components/CreateTweetForm.tsx
import { Button, message, Row, Col, Input } from 'antd';
import { mutate } from 'swr';
import { fetcher } from './util/fetcher';
import { useState } from 'react';
import { useFeed } from './util/hooks';

export const CreateTweetForm = () => {
  const [input, setInput] = useState('');
  const { feed } = useFeed();

  return (
    <form
      style={{ padding: '2rem' }}
      onSubmit={async (e) => {
        e.preventDefault();

        // we include "false" here to ask SWR not to revalidate the cache with
        // the feed returned from the server. we'll remove this after the next section
        mutate(
          '/api/feed',
          [{ text: input, author: { username: 'Marshall Mathers' } }, ...feed],
          false
        );

        setInput('');
      }}
    >
      <Row>
        <Col>
          <Input value={input} onChange={(e) => setInput(e.target.value)} />
        </Col>

        <Col>
          <Button htmlType="submit">Tweet</Button>
        </Col>
      </Row>
    </form>
  );
};

Import CreateTweetForm into the index page and render it directly above the Feed component. Be sure to test this on localhost to ensure you can spam your feed with every thought with which you desire.

The only problem, you may have noticed, is that tweets don't stick around if you refresh your browser. This is because we are currently adding new tweets to SWR's local cache, but is not being sent to the backend or stored anywhere that persist independently of browser sessions and devices.

Essentially, we need a way for:

  1. Tweets to stick around if you refresh the browser or go take a nap.
  2. Your friends to be able to see your tweets on their computers.

Enter: sqlite + Prisma

Now that we've got our app working nicely on single-player instances, we need to make it immune to the effects of time and refreshes by storing all of our users' data somewhere persistent. We do this by bringing in our old friend, the database. With it, we'll use Prisma to handle the datamodel, access the data, and give us type safety throughout the application.

We start by adding prisma to our project

yarn add prisma

Then initialize the prisma project with

npx prisma init

You'll notice that a prisma directory was created, and within it a schema.prisma file and a .env. You can ignore the latter for now, since we'll be using sqlite to get started and prototype faster, and switching to postgres later as we prepare for deployment.

To configure prisma to use sqlite and to point the prisma client to a local sqlite file on your machine, update the datasource and client attributes within schema.prisma

datasource sqlite {
  provider = "sqlite"
  url      = "file:./dev.db"
}

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native"]
}

Now, we can add a model for new tweets

prisma/schema.prisma
model Tweet {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @default(now())
  text     String
}

When creating a tweet, the id will be automatically generated and assigned an integer, starting from 1 and incrementing from there, and the createdAt and updatedAt fields will automatically be filled with timestamps at the moment of creation. So, all we need to do is pass a valid string into the text field, and we'll have created our first Tweet entry.

So, we can create a tweet with

const tweet = await prisma.tweet.create({ data: { text: 'Hello, Twitter!' } });

Now let's put this to use to allow users to create tweets.

Generate the prisma client

Before we begin, let's add some scripts to package.json to make it easier for us to call prisma migrate commands, as well a few more to facilitate the build process for when we deploy our app to production.

"scripts": {
    "migrate": "prisma migrate dev --preview-feature",
    "generate":"prisma generate",
    "dev": "next",
    "start": "next start",
    "build": "next build"
},

Now that we're set up, we can create the sqlite database file, run the migration to create the new table, and then generate the prisma client to create and access tweets.

First, we create the sqlite file and run the migration.

yarn migrate

Now, we can generate the prisma client, which lives in the node_modules directory and is generated on the fly (usually in a postintall hook) to give us up-to-date typesafe access to our data.

Create the client by running

yarn generate

Which peeks into our schema file for the models defined, generates the client in node_modules/@prisma/client and concludes with some output dictating exactly how we can use it in our code.

Actually creating tweets

Within the api directory, create another directory tweet, and within that create.ts. This will be another backend function that takes some text and gives us back a tweet object.

pages/api/tweet/create.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export default async (req, res) => {
  const { text } = req.body;
  const tweet = await prisma.tweet.create({ data: { text } });
  res.json(tweet);
};

Notice that we are assuming the text to be attached to the body of the request.

Now, lets try calling this function from our frontend. After the call to mutate in our index page, add

components/CreateTweetForm.tsx
fetcher('/api/tweet/create', {
  text: input
});

Remember to import { fetcher } from "./util/fetcher" at the top of the file.

Now, try creating another tweet in the browser and head to the Network tab of the console to see the results. You should see a request titled create, after the suffix of the endpoint, and click it to view the resposne. If the response worked, you'll see a response JSON object with an id, createdAt, and text fields.

Feed 2.0

Now that we can create tweets in our database, let's change our feed API function to retrieve tweets from the database instead of giving us back hardcoded data. Open pages/api/feed.ts and change the contents to

pages/api/feed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export default async (req, res) => {
  const tweets = await prisma.tweet.findMany({
    orderBy: { createdAt: 'desc' }
  });
  res.json(tweets);
};

and you'll now be able to create tweets, refresh the page, and see them live on.

Authentication

Our app can't compete with twitter if you can't log in and no one knows who's posts are whose, can it? Let's fix this by giving users a way to log in.

We're going to allow users to sign up, encrypt their passwords with bcrypt then authorize their device by attaching a server-side HttpOnly cookie to their requests.

Basically, we're gonna build a safe and secure way to allow users to sign in with passwords while making sure they or we don't get hacked.

First, we introduce the User model in schema.prisma

prisma/schema.prisma
model User {
  id          Int      @id @default(autoincrement())
  createdAt   DateTime @default(now())
  username    String @unique
  password    String
  tweets      Tweet[]
}

You'll notice that every user has a tweets field that corresponds to an array of Tweets. This allows us to access a users tweets as simply as with user.tweets. Also, let's add the other side of the relationship by adding authorId and author fields to Tweet.

prisma/schema.prisma
model Tweet {
  id         Int      @id @default(autoincrement())
  createdAt  DateTime @default(now())
  text       String
+  authorId   Int
+  author     User @relation(fields: [authorId], references:[id])
}

Now, we can save and run our database migrations to apply our changes. Let's use the scripts we added to package.json earlier.

yarn migrate
yarn generate

Then, we add our new dependencies

yarn add bcrypt jsonwebtoken cookie

We're going to handle authentication and reflect this to the user by rendering a Profile component on the page, which will show the user's details if they're logged in, and a SignupForm otherwise.

Let's start by creating Profile.tsx

components/Profile.tsx
import { Row, Col, Button, message } from 'antd';
import { SignupForm } from './SignupForm';
import { useMe } from './util/hooks';
import { useState } from 'react';

export const Profile = () => {
  const { me } = useMe();
  const [loading, setLoading] = useState(false);
  if (!me) return null;

  return (
    <Row style={{ padding: '1.5rem' }}>
      {!me.username ? (
        <SignupForm />
      ) : (
        <Col>
          Logged in as: <strong>{me.username}</strong>
          {/* TODO: we'll add a logout button here */}
        </Col>
      )}
    </Row>
  );
};

Now, render <Profile /> in pages/index.tsx so we can view our new component.

You'll notice we're using a new hook, useMe. As you can guess, this will return the currently authenticated user. Let's go ahead and add this hook to our hooks utility.

components/util/hooks.tsx
import { User } from '@prisma/client';

// useFeed function

export function useMe() {
  const { data: me }: { data?: User } = useSWR('/api/me', fetcher);
  return { me };
}

Notice that we're importing the User interface from prisma and applying it to the return type of the hook. This will give us typesafety through the frontend when working with our data, and is one of the most powerful advantages of using Prisma with React Hooks.

Also, our use of SWR will automatically deduplicate uses of useMe since they have the same key, /api/me. This means we can call useMe in several different components, and our app will only make a single request to the backend.

We'll implement the /api/me endpoint right after we've built the signup form and endpoints.

The SignupForm

Then we can create the form itself

components/SignupForm.tsx
import { Row, Col, Button, message, Input } from 'antd';
import { useState } from 'react';
import { mutate } from 'swr';
import { fetcher } from './util/fetcher';
export const SignupForm = ({}) => {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [login, setLogin] = useState(false);
  const [loading, setLoading] = useState(false);
  return (
    <Row>
      <Col>
        <h3>Sign up</h3>
        <form
          onSubmit={async (e) => {
            e.preventDefault();
            if (username.length === 0 || password.length === 0) {
              message.error(
                "Uh oh: you can't have a blank username or password."
              );
            }
            setLoading(true);
            const { data, error } = await fetcher(
              `/api/${login ? 'login' : 'signup'}`,
              {
                username,
                password
              }
            );
            if (error) {
              message.error(error);
              setLoading(false);
              return;
            }
            await mutate('/api/me');
          }}
        >
          <div>
            <Input
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              type="name"
              placeholder="Username"
            />

            <Input
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              type="password"
              placeholder="Password"
            />
          </div>
          <div>
            <Button htmlType="submit" loading={loading}>
              {login ? 'Login' : 'Sign up'}
            </Button>
          </div>
          <div>
            <a onClick={() => setLogin(!login)}>
              {login ? 'New? Sign Up' : 'Already a user? Log In'}
            </a>
          </div>
        </form>
      </Col>
    </Row>
  );
};

Notice that our Signup form also serves as a login form, and can switch between the two. Also, it will post to the endpoint /api/signup if the user is signing up, and to /api/login otherwise. As you may have guessed, now we'll have to create these two API files to handle the signing up and logging in process themselves.

Let's start with signup which

  1. Salts and hashes the password with Bcrypt
  2. Creates a User in the database with prisma
  3. Signs a jsonwebtoken with the id and username of the user and the JWT_SECRET from the environment.
  4. Sets an httpOnly cookie
pages/api/signup.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import cookie from 'cookie';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export default async (req, res) => {
  const salt = bcrypt.genSaltSync();
  const { username, password } = req.body;
  let user;
  try {
    user = await prisma.user.create({
      data: {
        username,
        password: bcrypt.hashSync(password, salt)
      }
    });
  } catch (error) {
    res.json({ error: 'A user with that username already exists 😮' });
    return;
  }
  const token = jwt.sign(
    { username: user.username, id: user.id, time: new Date() },
    process.env.JWT_SECRET,
    {
      expiresIn: '6h'
    }
  );

  res.setHeader(
    'Set-Cookie',
    cookie.serialize('token', token, {
      httpOnly: true,
      maxAge: 6 * 60 * 60,
      path: '/',
      sameSite: 'lax',
      secure: process.env.NODE_ENV === 'production'
    })
  );
  res.json(user);
  return;
};

Now, we can implement a similarly-structured login route.

pages/api/login.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import cookie from 'cookie';

export default async (req, res) => {
  const { username, password } = req.body;

  const user = await prisma.user.findUnique({
    where: { username }
  });

  if (user && bcrypt.compareSync(password, user.password)) {
    const token = jwt.sign(
      { username: user.username, id: user.id, time: new Date() },
      process.env.JWT_SECRET,
      {
        expiresIn: '6h'
      }
    );

    res.setHeader(
      'Set-Cookie',
      cookie.serialize('token', token, {
        httpOnly: true,
        maxAge: 6 * 60 * 60,
        path: '/',
        sameSite: 'lax',
        secure: process.env.NODE_ENV === 'production'
      })
    );

    res.json(user);
  } else {
    res.json({ error: 'Incorrect username or password 🙁' });
    return;
  }
};

Before we forget, create a .env file in the top-level directory and add

JWT_SECRET=appsecret123

Replace appsecret123 with some less-guessable combination of characters, and restart your development server.

The Me Endpoint

Finally, we can build /api/me

pages/api/me.ts
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export default async (req, res) => {
  const { token } = req.cookies;

  if (token) {
    const { id, username } = jwt.verify(token, process.env.JWT_SECRET);
    const me = await prisma.user.findUnique({ where: { id } });
    res.json(me);
  } else {
    res.json({});
  }
};

Also, we can return the author of each tweet in the feed, so that we can render their usernames

pages/api/feed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export default async (req, res) => {
  const tweets = await prisma.tweet.findMany({
    orderBy: { createdAt: 'desc' },
    include: { author: { select: { username: true, id: true } } }
  });
  res.json(tweets);
};

If you're using VSCode and hover over the tweets variable, typescript will show us that the feed is now of type

const tweets: (Tweet & {
  user: {
    author: {
      username: string;
      id: number;
    };
  };
})[];

So we can update our useFeed hook to return the same type, the same as we did for useMe earlier.

components/util/hooks.tsx
import { Tweet, User } from '@prisma/client';
import useSWR from 'swr';
import { fetcher } from './fetcher';

export function useFeed() {
  const { data: feed }: { data?: (Tweet & { author: User })[] } = useSWR(
    '/api/feed',
    fetcher
  );
  return { feed };
}
export function useMe() {
  const { data: me }: { data?: User } = useSWR('/api/me', fetcher);
  return { me };
}

Attaching tweets to authors

One last thing: we need to attach the logged in user to each tweet that's created as it's author. We do this by using the token the same way we do in /api/me, and then using the prisma client's connect property.

pages/api/tweet/create.ts
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
export default async (req, res) => {
  const prisma = new PrismaClient();

  const { token } = req.cookies;

  if (token) {
    // Get authenticated user
    const { _id, username } = jwt.verify(token, process.env.JWT_SECRET);
    const { text } = req.body;

    const tweet = await prisma.tweet.create({
      data: { text, author: { connect: { username } } }
    });
    res.json(tweet);
  } else {
    res.json({ error: 'You must be logged in to tweet.' });
  }
};

Now, test the app on localhost to make sure you can sign up and create new tweets.

Final Touches

It would be nice if users could delete their tweets after they've posted them, as well as log themselves out. Let's create some button components to facilitate this.

To know if a user can delete a tweet, we need to check if the user is the tweet's author. Then we can make a call to a new API endpoint for deleting a tweet, and then locally mutate the cache to remove the tweet from the feed.

Create a new component DeleteButton.tsx in components.

components/DeleteTweetButton
import { Button } from 'antd';
import { mutate } from 'swr';
import { fetcher } from './util/fetcher';

export const DeleteButton = ({ id, feed }) => (
  <Button
    style={{ float: 'right' }}
    danger
    type="dashed"
    onClick={async () => {
      await fetcher('/api/tweet/delete', { id });
      await mutate(
        '/api/feed',
        feed.filter((t) => t.id !== id)
      );
    }}
  >
    x
  </Button>
);

and import and render it in the feed component.

components/Feed.tsx
+ import { DeleteButton } from "./DeleteButton";
...

 <Card key={i}>
+  {me && author.id === me.id && (
+    <DeleteButton id={id} feed={feed} />
+  )}
  <h4>{text}</h4>
  <span>{author.username}</span>
 </Card>

Now, let's create the backend half of the equation. Create a new API route for /api/tweet/delete that takes the id from the body of the request, then passes it to prisma's delete method, and returns the (now empty) tweet.

pages/api/tweet/delete

import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export default async (req, res) => {
  const { id } = req.body;

  const tweet = await prisma.tweet.delete({
    where: { id }
  });

  res.json(tweet);
  return;
};

Test to make sure you can now delete your own (and only your own) tweets.

Great! Now let's follow the very same process to facillitate logging out.

Create a LogoutButton.tsx

components/LogoutButton.tsx
import { Button, message } from 'antd';
import { mutate } from 'swr';
import { fetcher } from './util/fetcher';
import { useState } from 'react';
export const LogoutButton = () => {
  const [loading, setLoading] = useState(false);
  return (
    <Button
      loading={loading}
      onClick={async () => {
        setLoading(true);
        const { data, error } = await fetcher('/api/logout');
        if (error) {
          message.error(error);
          setLoading(false);
          return;
        }
        await mutate('/api/me');
      }}
    >
      Log Out
    </Button>
  );
};

Then import and render it in Profile

components/Profile.tsx
+ import { LogoutButton } from "./LogoutButton";
...

 <Col>
    Logged in as: <strong>{me.username}</strong>
    <br />
+   <LogoutButton  />

 </Col>

For our final step, we create the logout API route

pages/api/logout.ts
import { serialize } from 'cookie';

export default (req, res) => {
  const cookie = serialize('token', '', {
    maxAge: -1,
    path: '/'
  });

  res.setHeader('Set-Cookie', cookie);
  res.json({ loggedOut: true });
};

Try and logout, and behold that our app is complete.

Need help?

I'm most responsive via email at me@kunal.sh but feel free to DM me on twitter.