├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── grafbase ├── .env.example └── schema.graphql ├── next-auth.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── og.png └── sent.wav ├── src ├── components │ ├── apollo-provider-wrapper.tsx │ ├── header.tsx │ ├── message-list.tsx │ ├── message.tsx │ └── new-message-form.tsx ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ └── auth │ │ │ ├── [...nextauth].ts │ │ │ └── token.ts │ └── index.tsx └── styles │ └── globals.css ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # This must be your local or Grafbase production API endpoint 2 | # Click "Connect" inside your Grafbase Project Dashboard to get the URL 3 | NEXT_PUBLIC_GRAFBASE_API_URL=http://localhost:4000/graphql 4 | 5 | # The NEXTAUTH_SECRET must match that set inside the grafbase/.env. 6 | # You can generate one here: https://generate-secret.vercel.app 7 | NEXTAUTH_SECRET= 8 | 9 | # Create a new GitHub app in your account settings to obtain these values 10 | GITHUB_CLIENT_ID= 11 | GITHUB_CLIENT_SECRET= 12 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | grafbase/.env 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chatbase 2 | 3 | Realtime chat using GraphQL Live Queries, Next.js and NextAuth.js — [tutorial](https://grafbase.com/guides/how-to-build-a-real-time-chat-app-with-nextjs-graphql-and-server-sent-events) 4 | 5 | ![Chatbase App](/public/og.png) 6 | 7 | ## Tools used 8 | 9 | - NextAuth.js 10 | - Next.js 11 | - Apollo Client 12 | - Grafbase 13 | - Server-Sent Events 14 | - GraphQL Live Queries 15 | - GraphQL 16 | - Tailwind CSS 17 | 18 | ## Local Development 19 | 20 | 1. `npm install` 21 | 2. Create a [GitHub OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) with your app details for development purposes. Make sure to set `Authorization callback URL` to `http://localhost:3000/api/auth/callback/github` 22 | 3. `cp .env.example .env` and add values for `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` from step 2. 23 | 4. [Generate a secret value](https://generate-secret.vercel.app) for `NEXTAUTH_SECRET` and add it to `.env` 24 | 5. `cp grafbase/.env.example grafbase/.env` 25 | 6. Add the same `NEXTAUTH_SECRET` to `grafbase/.env` 26 | 7. `npx grafbase dev` 27 | 8. `npm run dev` 28 | 29 | ## Deploy to Production 30 | 31 | 1. Fork and Push this repo to GitHub 32 | 2. [Create an account](https://grafbase.com) with Grafbase 33 | 3. Create new project with Grafbase and connect your forked repo 34 | 4. Add environment variable `NEXTAUTH_SECRET` during project creation 35 | 5. Create a [GitHub OAuth App](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) with your app details for production purposes. Make sure to set `Authorization callback URL` to `[YOUR_DESIRED_VERCEL_DOMAIN]/api/auth/callback/github` 36 | 6. Deploy to Vercel and add `.env` values (`NEXT_PUBLIC_GRAFBASE_API_URL`\*, `NEXTAUTH_SECRET`, `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`) 37 | 38 | \* `NEXT_PUBLIC_GRAFBASE_URL` is your production API endpoint. You can find this from the **Connect** modal in your [project dashboard](https://grafbase.com/dashboard). 39 | -------------------------------------------------------------------------------- /grafbase/.env.example: -------------------------------------------------------------------------------- 1 | # This value must be the same as the root .env 2 | # You can generate one here: https://generate-secret.vercel.app 3 | NEXTAUTH_SECRET= -------------------------------------------------------------------------------- /grafbase/schema.graphql: -------------------------------------------------------------------------------- 1 | schema 2 | @auth( 3 | providers: [ 4 | { type: jwt, issuer: "nextauth", secret: "{{ env.NEXTAUTH_SECRET }}" } 5 | ] 6 | rules: [{ allow: private }] 7 | ) { 8 | query: Query 9 | } 10 | 11 | type Message @model { 12 | username: String! 13 | avatar: URL 14 | body: String! 15 | likes: Int @default(value: 0) 16 | dislikes: Int @default(value: 0) 17 | } 18 | -------------------------------------------------------------------------------- /next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultSession, JWT } from "next-auth"; 2 | import NextAuth from "next-auth"; 3 | 4 | declare module "next-auth" { 5 | interface Session { 6 | username?: string; 7 | user: { 8 | username?: string; 9 | } & DefaultSession["user"]; 10 | } 11 | interface Profile { 12 | login?: string; 13 | } 14 | } 15 | 16 | declare module "next-auth/jwt" { 17 | interface JWT { 18 | username?: string; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | domains: ["avatars.githubusercontent.com"], 6 | }, 7 | }; 8 | 9 | module.exports = nextConfig; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chatbase", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@apollo/client": "3.7.9", 13 | "@grafbase/apollo-link": "1.1.0", 14 | "@types/node": "18.14.5", 15 | "@types/react": "18.0.28", 16 | "@types/react-dom": "18.0.11", 17 | "date-fns": "2.30.0", 18 | "eslint": "8.35.0", 19 | "eslint-config-next": "13.2.3", 20 | "graphql": "16.6.0", 21 | "jsonwebtoken": "9.0.0", 22 | "next": "13.2.3", 23 | "next-auth": "4.20.1", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-intersection-observer": "9.4.3", 27 | "typescript": "4.9.5", 28 | "use-sound": "4.0.1" 29 | }, 30 | "devDependencies": { 31 | "@types/jsonwebtoken": "9.0.1", 32 | "autoprefixer": "10.4.13", 33 | "postcss": "8.4.21", 34 | "tailwindcss": "3.2.7" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notrab/chatbase/f0a5d02320ac602a1b7f4b4ad155a2d4ec79b8a4/public/og.png -------------------------------------------------------------------------------- /public/sent.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/notrab/chatbase/f0a5d02320ac602a1b7f4b4ad155a2d4ec79b8a4/public/sent.wav -------------------------------------------------------------------------------- /src/components/apollo-provider-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren } from "react"; 2 | import { useMemo } from "react"; 3 | import { 4 | ApolloClient, 5 | ApolloProvider, 6 | HttpLink, 7 | InMemoryCache, 8 | split, 9 | from, 10 | } from "@apollo/client"; 11 | import { SSELink, isLiveQuery } from "@grafbase/apollo-link"; 12 | import { getOperationAST } from "graphql"; 13 | import { setContext } from "@apollo/client/link/context"; 14 | 15 | const httpLink = new HttpLink({ 16 | uri: process.env.NEXT_PUBLIC_GRAFBASE_API_URL, 17 | }); 18 | 19 | const sseLink = new SSELink({ 20 | uri: process.env.NEXT_PUBLIC_GRAFBASE_API_URL!, 21 | }); 22 | 23 | export const ApolloProviderWrapper = ({ children }: PropsWithChildren) => { 24 | const client = useMemo(() => { 25 | const authMiddleware = setContext(async (_, { headers }) => { 26 | const { token } = await fetch("/api/auth/token").then((res) => 27 | res.json() 28 | ); 29 | 30 | return { 31 | headers: { 32 | ...headers, 33 | authorization: `Bearer ${token}`, 34 | }, 35 | }; 36 | }); 37 | 38 | return new ApolloClient({ 39 | link: from([ 40 | authMiddleware, 41 | split( 42 | ({ query, operationName, variables }) => 43 | isLiveQuery(getOperationAST(query, operationName), variables), 44 | sseLink, 45 | httpLink 46 | ), 47 | ]), 48 | cache: new InMemoryCache(), 49 | }); 50 | }, []); 51 | 52 | return {children}; 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { signIn, signOut, useSession } from "next-auth/react"; 2 | import Image from "next/image"; 3 | 4 | export function Header() { 5 | const { data: session } = useSession(); 6 | 7 | return ( 8 |
9 |
10 |
11 |

12 | 17 | 24 | 29 | 34 | 35 | 36 | Chatbase 37 |

38 | {session ? ( 39 |
40 | {session?.user?.image && ( 41 |
42 | {session?.user?.name 49 |
50 | )} 51 | 57 |
58 | ) : ( 59 |
60 | 66 |
67 | )} 68 |
69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/components/message-list.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery, gql } from "@apollo/client"; 2 | import { useEffect } from "react"; 3 | import { useInView } from "react-intersection-observer"; 4 | 5 | import type { Message as IMessage } from "@/components/message"; 6 | import { Message } from "@/components/message"; 7 | 8 | const GetRecentMessagesQuery = gql` 9 | query GetRecentMessages($last: Int) @live { 10 | messageCollection(last: $last) { 11 | edges { 12 | node { 13 | id 14 | username 15 | avatar 16 | body 17 | likes 18 | createdAt 19 | } 20 | } 21 | } 22 | } 23 | `; 24 | 25 | export const MessageList = () => { 26 | const [scrollRef, inView, entry] = useInView({ 27 | trackVisibility: true, 28 | delay: 1000, 29 | }); 30 | 31 | const { loading, error, data } = useQuery<{ 32 | messageCollection: { edges: { node: IMessage }[] }; 33 | }>(GetRecentMessagesQuery, { 34 | variables: { 35 | last: 100, 36 | }, 37 | }); 38 | 39 | useEffect(() => { 40 | if (entry?.target) { 41 | entry.target.scrollIntoView({ behavior: "smooth", block: "end" }); 42 | } 43 | }, [data?.messageCollection.edges.length, entry?.target]); 44 | 45 | if (loading) 46 | return ( 47 |
48 |

Fetching most recent chat messages.

49 |
50 | ); 51 | 52 | if (error) 53 | return ( 54 |

Something went wrong. Refresh to try again.

55 | ); 56 | 57 | return ( 58 |
59 | {!inView && data?.messageCollection.edges.length && ( 60 |
61 | 69 |
70 | )} 71 | {data?.messageCollection?.edges?.map(({ node }) => ( 72 | 73 | ))} 74 |
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /src/components/message.tsx: -------------------------------------------------------------------------------- 1 | import { formatRelative, formatDistance, differenceInHours } from "date-fns"; 2 | import { useSession } from "next-auth/react"; 3 | import Image from "next/image"; 4 | 5 | export type Message = { 6 | id: string; 7 | username: string; 8 | avatar?: string; 9 | body: string; 10 | createdAt: string; 11 | }; 12 | 13 | interface Props { 14 | message: Message; 15 | } 16 | 17 | export const Message = ({ message }: Props) => { 18 | const { data: session } = useSession(); 19 | 20 | return ( 21 |
26 |
33 | {message?.avatar && ( 34 |
35 | 40 | {message.username} 47 | 48 |
49 | )} 50 | 57 | {message.username !== session?.username && ( 58 | {message.username}:  59 | )} 60 | {message.body} 61 | 62 |
63 |

64 | {differenceInHours(new Date(), new Date(message.createdAt)) >= 1 65 | ? formatRelative(new Date(message.createdAt), new Date()) 66 | : formatDistance(new Date(message.createdAt), new Date(), { 67 | addSuffix: true, 68 | })} 69 |

70 |
71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/new-message-form.tsx: -------------------------------------------------------------------------------- 1 | import { gql, useMutation } from "@apollo/client"; 2 | import { useSession } from "next-auth/react"; 3 | import { useState } from "react"; 4 | import useSound from "use-sound"; 5 | 6 | const AddNewMessageMutation = gql` 7 | mutation AddNewMessage($username: String!, $avatar: URL, $body: String!) { 8 | messageCreate( 9 | input: { username: $username, avatar: $avatar, body: $body } 10 | ) { 11 | message { 12 | id 13 | } 14 | } 15 | } 16 | `; 17 | 18 | export const NewMessageForm = () => { 19 | const { data: session } = useSession(); 20 | const [play] = useSound("sent.wav"); 21 | const [body, setBody] = useState(""); 22 | const [addNewMessage] = useMutation(AddNewMessageMutation, { 23 | onCompleted: () => play(), 24 | }); 25 | 26 | return ( 27 |
{ 29 | e.preventDefault(); 30 | 31 | if (body) { 32 | addNewMessage({ 33 | variables: { 34 | username: session?.username ?? "", 35 | avatar: session?.user?.image, 36 | body, 37 | }, 38 | }); 39 | setBody(""); 40 | } 41 | }} 42 | className="flex items-center space-x-3" 43 | > 44 | setBody(e.target.value)} 51 | className="flex-1 h-12 px-3 rounded bg-[#222226] border border-[#222226] focus:border-[#222226] focus:outline-none text-white placeholder-white" 52 | /> 53 | 60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | 3 | import type { AppProps } from "next/app"; 4 | import { SessionProvider } from "next-auth/react"; 5 | 6 | import { ApolloProviderWrapper } from "../components/apollo-provider-wrapper"; 7 | 8 | export default function App({ 9 | Component, 10 | pageProps: { session, ...pageProps }, 11 | }: AppProps) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { NextAuthOptions } from "next-auth"; 2 | import GitHubProvider from "next-auth/providers/github"; 3 | import jsonwebtoken from "jsonwebtoken"; 4 | import { JWT } from "next-auth/jwt"; 5 | 6 | export const authOptions: NextAuthOptions = { 7 | providers: [ 8 | GitHubProvider({ 9 | clientId: process.env.GITHUB_CLIENT_ID!, 10 | clientSecret: process.env.GITHUB_CLIENT_SECRET!, 11 | }), 12 | ], 13 | jwt: { 14 | encode: ({ secret, token }) => 15 | jsonwebtoken.sign( 16 | { 17 | ...token, 18 | iss: "nextauth", 19 | exp: Math.floor(Date.now() / 1000) + 60 * 60 * 60, 20 | }, 21 | secret 22 | ), 23 | decode: async ({ secret, token }) => 24 | jsonwebtoken.verify(token!, secret) as JWT, 25 | }, 26 | callbacks: { 27 | async jwt({ token, profile }) { 28 | if (profile) { 29 | token.username = profile?.login; 30 | } 31 | return token; 32 | }, 33 | session({ session, token }) { 34 | if (token.username) { 35 | session.username = token?.username; 36 | } 37 | return session; 38 | }, 39 | }, 40 | }; 41 | 42 | export default NextAuth(authOptions); 43 | -------------------------------------------------------------------------------- /src/pages/api/auth/token.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { getToken } from "next-auth/jwt"; 3 | import { getServerSession } from "next-auth/next"; 4 | 5 | import { authOptions } from "./[...nextauth]"; 6 | 7 | const secret = process.env.NEXTAUTH_SECRET; 8 | 9 | export default async function handler( 10 | req: NextApiRequest, 11 | res: NextApiResponse 12 | ) { 13 | const session = await getServerSession(req, res, authOptions); 14 | 15 | if (!session) { 16 | return res.send({ 17 | error: 18 | "You must be signed in to view the protected content on this page.", 19 | }); 20 | } 21 | 22 | const token = await getToken({ req, secret, raw: true }); 23 | 24 | res.json({ token }); 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from "next-auth/react"; 2 | 3 | import { Header } from "@/components/header"; 4 | import { MessageList } from "@/components/message-list"; 5 | import { NewMessageForm } from "@/components/new-message-form"; 6 | 7 | export default function Home() { 8 | const { data: session, status } = useSession(); 9 | 10 | return ( 11 |
12 |
13 | {session ? ( 14 | <> 15 |
16 |
17 |
18 | 19 |
20 |
21 |
22 |
23 |
24 | 25 |
26 |
27 | 28 | ) : ( 29 |
30 | {status === "loading" ? null : ( 31 | <> 32 |

33 | Sign in with GitHub to join the chat! 34 |

35 |

36 | 42 | Powered by Grafbase & GraphQL Live Queries 43 | 44 |

45 | 46 | )} 47 |
48 | )} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | body > div:first-child, 8 | div#__next, 9 | div#__next > div { 10 | height: 100%; 11 | } 12 | 13 | @layer utilities { 14 | @variants responsive { 15 | /* Hide scrollbar for Chrome, Safari and Opera */ 16 | .no-scrollbar::-webkit-scrollbar { 17 | display: none; 18 | } 19 | 20 | /* Hide scrollbar for IE, Edge and Firefox */ 21 | .no-scrollbar { 22 | -ms-overflow-style: none; /* IE and Edge */ 23 | scrollbar-width: none; /* Firefox */ 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./src/*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | --------------------------------------------------------------------------------