├── next-env.d.ts ├── public ├── demo.gif └── screenshot.png ├── components ├── .DS_Store ├── Field.tsx ├── Dashboard.tsx ├── Users.tsx ├── Profile.tsx ├── Copyright.tsx └── Posts.tsx ├── pages ├── index.tsx ├── admin.tsx ├── api │ └── graphql.ts ├── signout.tsx ├── _app.js ├── login.tsx └── signup.tsx ├── global.scss ├── apollo ├── schema.js ├── type-defs.js ├── resolvers.ts └── client.js ├── render.yaml ├── tsconfig.json ├── prisma └── schema.prisma ├── package.json ├── .gitignore └── README.md /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /public/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kunalgorithm/graphql-fullstack/HEAD/public/demo.gif -------------------------------------------------------------------------------- /components/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kunalgorithm/graphql-fullstack/HEAD/components/.DS_Store -------------------------------------------------------------------------------- /public/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kunalgorithm/graphql-fullstack/HEAD/public/screenshot.png -------------------------------------------------------------------------------- /components/Field.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "antd"; 2 | 3 | export default (props) => { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import Dashboard from "../components/Dashboard"; 2 | import { withApollo } from "../apollo/client"; 3 | 4 | export default withApollo(() => { 5 | return ; 6 | }); 7 | -------------------------------------------------------------------------------- /global.scss: -------------------------------------------------------------------------------- 1 | // body { 2 | // display: flex; 3 | // align-items: center; 4 | // justify-content: space-around; 5 | // } 6 | 7 | div { 8 | margin: 10px 0px; 9 | } 10 | 11 | .form { 12 | flex-direction: column; 13 | } 14 | -------------------------------------------------------------------------------- /apollo/schema.js: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from 'graphql-tools' 2 | import { typeDefs } from './type-defs' 3 | import { resolvers } from './resolvers' 4 | 5 | export const schema = makeExecutableSchema({ 6 | typeDefs, 7 | resolvers, 8 | }) 9 | -------------------------------------------------------------------------------- /render.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | - type: web 3 | name: graphql-fullstack 4 | env: node 5 | healthCheckPath: /healthz 6 | buildCommand: yarn install && yarn build 7 | startCommand: yarn start 8 | envVars: 9 | - key: DATABASE_URL 10 | fromDatabase: 11 | name: graphql-fullstack 12 | property: connectionString 13 | databases: 14 | - name: graphql-fullstack 15 | -------------------------------------------------------------------------------- /pages/admin.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useQuery } from "@apollo/react-hooks"; 4 | import { gql } from "apollo-boost"; 5 | import { withApollo } from "../apollo/client"; 6 | import Users from "../components/Users"; 7 | 8 | export default withApollo(() => { 9 | if (process.env.NODE_ENV === "production") 10 | return
This page is only viewable on localhost.
; 11 | 12 | return ; 13 | }); 14 | -------------------------------------------------------------------------------- /pages/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from "apollo-server-micro"; 2 | import { schema } from "../../apollo/schema"; 3 | import { PrismaClient } from "@prisma/client"; 4 | // or const { PrismaClient } = require('@prisma/client') 5 | 6 | const prisma = new PrismaClient(); 7 | const apolloServer = new ApolloServer({ 8 | schema, 9 | context: (request) => ({ 10 | ...request, 11 | prisma, 12 | }), 13 | }); 14 | 15 | export const config = { 16 | api: { 17 | bodyParser: false, 18 | }, 19 | }; 20 | 21 | export default apolloServer.createHandler({ path: "/api/graphql" }); 22 | -------------------------------------------------------------------------------- /components/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Profile from "./Profile"; 4 | import { Row, Col } from "antd"; 5 | import Users from "./Users"; 6 | import Posts from "./Posts"; 7 | 8 | export default function Dashboard() { 9 | return ( 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apollo/type-defs.js: -------------------------------------------------------------------------------- 1 | import gql from "graphql-tag"; 2 | 3 | export const typeDefs = gql` 4 | type User { 5 | id: ID! 6 | email: String! 7 | name: String 8 | password: String! 9 | posts: [Post!]! 10 | } 11 | type Post { 12 | id: ID! 13 | title: String! 14 | } 15 | 16 | type Query { 17 | users: [User!]! 18 | user(email: String!): User! 19 | me: User 20 | } 21 | 22 | type Mutation { 23 | signup(email: String!, name: String!, password: String!): User! 24 | login(email: String!, password: String!): User! 25 | signOut: Boolean! 26 | createPost(title: String!): Post! 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve" 17 | }, 18 | "exclude": ["node_modules"], 19 | "include": [ 20 | "next-env.d.ts", 21 | "**/*.ts", 22 | "**/*.tsx", 23 | "src/lib/with-apollo.js", 24 | "apollo" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /components/Users.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useQuery } from "@apollo/react-hooks"; 4 | import { gql } from "apollo-boost"; 5 | import { withApollo } from "../apollo/client"; 6 | 7 | export default () => { 8 | const { loading, error, data } = useQuery( 9 | gql` 10 | query { 11 | users { 12 | email 13 | name 14 | } 15 | } 16 | ` 17 | ); 18 | return ( 19 |
20 |

Users

21 |
22 | {data && 23 | data.users && 24 | data.users.map((user, i) => ( 25 |
26 | {user.name} - {user.email} 27 |
28 | ))} 29 |
30 |
31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource sqlite { 5 | provider = "sqlite" 6 | url = "file:./dev.db" 7 | } 8 | 9 | generator client { 10 | provider = "prisma-client-js" 11 | binaryTargets = ["native"] 12 | } 13 | 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | createdAt DateTime @default(now()) 17 | email String @unique 18 | name String? 19 | password String 20 | // role Role @default(USER) 21 | posts Post[] 22 | } 23 | 24 | model Post { 25 | id Int @id @default(autoincrement()) 26 | createdAt DateTime @default(now()) 27 | title String 28 | 29 | } 30 | -------------------------------------------------------------------------------- /pages/signout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useMutation, useApolloClient } from "@apollo/react-hooks"; 3 | import gql from "graphql-tag"; 4 | import { useRouter } from "next/router"; 5 | import { withApollo } from "../apollo/client"; 6 | 7 | const SignOutMutation = gql` 8 | mutation SignOutMutation { 9 | signOut 10 | } 11 | `; 12 | 13 | function SignOut() { 14 | const client = useApolloClient(); 15 | const router = useRouter(); 16 | const [signOut] = useMutation(SignOutMutation); 17 | 18 | React.useEffect(() => { 19 | signOut().then(() => { 20 | client.resetStore().then(() => { 21 | router.push("/"); 22 | }); 23 | }); 24 | }, [signOut, router, client]); 25 | 26 | return

Signing out...

; 27 | } 28 | 29 | export default withApollo(SignOut); 30 | -------------------------------------------------------------------------------- /components/Profile.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { useQuery } from "@apollo/react-hooks"; 4 | import { gql } from "apollo-boost"; 5 | import { withApollo } from "../apollo/client"; 6 | 7 | import { Input, Button } from "antd"; 8 | import Link from "next/link"; 9 | 10 | const Profile = () => { 11 | const { loading, error, data, client } = useQuery( 12 | gql` 13 | query { 14 | me { 15 | id 16 | name 17 | email 18 | } 19 | } 20 | ` 21 | ); 22 | if (loading) return
Loading...
; 23 | if (!data || !data.me) 24 | return ( 25 |
26 |

You are not logged in.

27 | 28 | Login 29 | {" "} 30 | or{" "} 31 | 32 | Sign up 33 | 34 |
35 | ); 36 | return ( 37 |
38 |
39 |

Profile

40 | 41 |
42 | {data.me.name} 43 |
44 | {data.me.email} 45 | 46 |
47 | 48 | 49 | 50 |
51 |
52 |
53 |
54 | ); 55 | }; 56 | 57 | export default Profile; 58 | -------------------------------------------------------------------------------- /components/Copyright.tsx: -------------------------------------------------------------------------------- 1 | export default () => { 2 | return ( 3 |
4 | 8 | View on GitHub 9 | 22 | 23 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-fullstack", 3 | "homepage": "https://graphql-fullstack.now.sh", 4 | "author": { 5 | "name": "Kunal Shah", 6 | "email": "me@kunal.sh" 7 | }, 8 | "version": "1.0.0", 9 | "license": "MIT", 10 | "scripts": { 11 | "dev": "next", 12 | "build": "next build", 13 | "now-build": "next build && prisma generate", 14 | "start": "next start", 15 | "generate": "prisma generate", 16 | "migrate": "yarn migrate:save && yarn migrate:up", 17 | "migrate:save": "prisma migrate save --experimental", 18 | "migrate:up": "prisma migrate up --experimental" 19 | }, 20 | "dependencies": { 21 | "@apollo/react-hooks": "^3.1.0", 22 | "@apollo/react-ssr": "^3.1.0", 23 | "@prisma/client": "^2.0.0-beta.2", 24 | "antd": "^4.0.4", 25 | "apollo-boost": "^0.4.4", 26 | "apollo-link-context": "^1.0.19", 27 | "apollo-link-error": "^1.1.13", 28 | "apollo-link-schema": "^1.2.5", 29 | "apollo-server-micro": "2.6.7", 30 | "bcrypt": "^4.0.1", 31 | "bcryptjs": "^2.4.3", 32 | "cookie": "^0.4.0", 33 | "graphql": "14.4.2", 34 | "graphql-tag": "^2.10.1", 35 | "isomorphic-unfetch": "^3.0.0", 36 | "jsonwebtoken": "^8.5.1", 37 | "next": "^9.4.0", 38 | "prettier": "^2.0.4", 39 | "react": "^16.13.1", 40 | "react-dom": "^16.13.1", 41 | "recharts": "^1.7.1", 42 | "sass": "^1.26.3" 43 | }, 44 | "devDependencies": { 45 | "@prisma/cli": "^2.0.0-beta.2", 46 | "@types/react": "^16.9.2", 47 | "tslint": "^5.19.0", 48 | "typescript": "^3.6.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | migrations 4 | .env 5 | .DS_Store 6 | 7 | ### Node ### 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # TypeScript v1 declaration files 52 | typings/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'npm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | 79 | # next.js build output 80 | .next 81 | 82 | # nuxt.js build output 83 | .nuxt 84 | 85 | # vuepress build output 86 | .vuepress/dist 87 | 88 | # Serverless directories 89 | .serverless/ 90 | 91 | # FuseBox cache 92 | .fusebox/ 93 | 94 | # DynamoDB Local files 95 | .dynamodb/ 96 | 97 | # End of https://www.gitignore.io/api/node 98 | 99 | .now -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import App, { Container } from "next/app"; 3 | import Head from "next/head"; 4 | import Copyright from "../components/Copyright"; 5 | import { Layout, Menu } from "antd"; 6 | import Link from "next/link"; 7 | 8 | const { Header, Footer, Sider, Content } = Layout; 9 | //@ts-ignore 10 | import "antd/dist/antd.css"; 11 | import "../global.scss"; 12 | class MyApp extends App { 13 | render() { 14 | const { Component, pageProps } = this.props; 15 | 16 | return ( 17 | <> 18 | 19 | GraphQL Fullstack Web App 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | Home 28 | 29 | 30 | 31 | 32 | Log In 33 | 34 | 35 | 36 | 37 | Sign Up 38 | 39 | 40 | 41 | 42 | Log Out 43 | 44 | 45 | 46 |
47 | 48 | {/* Sider */} 49 | 50 | 51 | 52 | 53 |
54 | 55 |
56 |
57 | 58 | ); 59 | } 60 | } 61 | 62 | export default MyApp; 63 | -------------------------------------------------------------------------------- /components/Posts.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | 3 | import { useQuery, useMutation } from "@apollo/react-hooks"; 4 | import { gql } from "apollo-boost"; 5 | import { withApollo } from "../apollo/client"; 6 | 7 | import { Input, Button, Card } from "antd"; 8 | import Link from "next/link"; 9 | import Field from "./Field"; 10 | 11 | export default () => { 12 | const { loading, error, data, client } = useQuery( 13 | gql` 14 | query { 15 | me { 16 | id 17 | name 18 | email 19 | posts { 20 | id 21 | title 22 | } 23 | } 24 | } 25 | ` 26 | ); 27 | 28 | useEffect(() => { 29 | if (data && data.me) setPosts(data.me.posts); 30 | }, [data]); 31 | 32 | const [posts, setPosts] = useState([]); 33 | const [input, setInput] = useState(""); 34 | 35 | const [createPost] = useMutation(gql` 36 | mutation createPost($title: String!) { 37 | createPost(title: $title) { 38 | title 39 | } 40 | } 41 | `); 42 | 43 | return ( 44 |
45 |

Posts

46 | {!data || (!data.me && Log in to create posts.)} 47 |
{ 50 | e.preventDefault(); 51 | setPosts([{ title: input }, ...posts]); 52 | await createPost({ variables: { title: input } }); 53 | setInput(""); 54 | }} 55 | > 56 | setInput(e.target.value)} 65 | /> 66 | 67 |
68 | {posts.map((post, i) => ( 69 | 70 |

{post.title}

71 |
72 | ))} 73 |
74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /pages/login.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { gql } from "apollo-boost"; 3 | import Link from "next/link"; 4 | import { useMutation } from "@apollo/react-hooks"; 5 | import { Input, Button, message, Row, Col } from "antd"; 6 | import { useRouter } from "next/router"; 7 | 8 | import { withApollo } from "../apollo/client"; 9 | import Field from "../components/Field"; 10 | 11 | const LOGIN_MUTATION = gql` 12 | mutation Login($email: String!, $password: String!) { 13 | login(email: $email, password: $password) { 14 | email 15 | name 16 | } 17 | } 18 | `; 19 | 20 | function SignIn() { 21 | const [loginMutation, { error, client, loading }] = useMutation( 22 | LOGIN_MUTATION 23 | ); 24 | const router = useRouter(); 25 | 26 | return ( 27 | 28 | 29 |

Log in

30 | 31 |
{ 34 | e.preventDefault(); 35 | e.stopPropagation(); 36 | 37 | const { 38 | email, 39 | password, 40 | //@ts-ignore 41 | } = event.currentTarget.elements; 42 | 43 | try { 44 | await client.resetStore(); 45 | const result: { data?: any } = await loginMutation({ 46 | variables: { 47 | email: email.value, 48 | password: password.value, 49 | }, 50 | }); 51 | 52 | if (result.data && result.data.login) { 53 | await router.push("/"); 54 | } 55 | } catch (error) { 56 | message.error(error.message); 57 | } 58 | }} 59 | > 60 | 67 | 68 | 75 | 76 |
77 | 80 |
81 | 82 |
83 | 84 | Don't have an account? Sign Up 85 | 86 |
87 | 88 | 89 |
90 | ); 91 | } 92 | 93 | export default withApollo(SignIn); 94 | -------------------------------------------------------------------------------- /pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | 3 | import { useRouter } from "next/router"; 4 | 5 | import { gql } from "apollo-boost"; 6 | import { useMutation } from "@apollo/react-hooks"; 7 | 8 | import { withApollo } from "../apollo/client"; 9 | import Link from "next/link"; 10 | import { Input, Button, message, Row, Col } from "antd"; 11 | import Field from "../components/Field"; 12 | 13 | const SIGNUP_MUTATION = gql` 14 | mutation Signup($name: String!, $email: String!, $password: String!) { 15 | signup(name: $name, email: $email, password: $password) { 16 | name 17 | email 18 | } 19 | } 20 | `; 21 | 22 | function SignUp() { 23 | const router = useRouter(); 24 | 25 | const [signupMutation, { loading, error, data, client }] = useMutation( 26 | SIGNUP_MUTATION 27 | ); 28 | 29 | return ( 30 | 31 | 32 |

Sign up

33 |
{ 36 | e.preventDefault(); 37 | e.stopPropagation(); 38 | 39 | const { 40 | email, 41 | name, 42 | password, 43 | //@ts-ignore 44 | } = event.currentTarget.elements; 45 | 46 | try { 47 | await client.resetStore(); 48 | const result: { data?: any } = await signupMutation({ 49 | variables: { 50 | email: email.value, 51 | password: password.value, 52 | name: name.value, 53 | }, 54 | }); 55 | 56 | if (result.data && result.data.signup) { 57 | await router.push("/"); 58 | } 59 | } catch (error) { 60 | message.error(error.message); 61 | } 62 | }} 63 | > 64 | 71 | 72 | 73 | 80 | 81 |
82 | 85 |
86 | 87 | 88 | Already have an account? Log in 89 | 90 | 91 | 92 |
93 | ); 94 | } 95 | 96 | export default withApollo(SignUp); 97 | -------------------------------------------------------------------------------- /apollo/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationError, UserInputError } from "apollo-server-micro"; 2 | import cookie from "cookie"; 3 | import jwt from "jsonwebtoken"; 4 | import getConfig from "next/config"; 5 | import bcrypt from "bcrypt"; 6 | import v4 from "uuid/v4"; 7 | import { PrismaClient, User, Post } from "@prisma/client"; 8 | import { NextApiRequest, NextApiResponse } from "next"; 9 | 10 | export interface Context { 11 | prisma: PrismaClient; 12 | req: NextApiRequest; 13 | res: NextApiResponse; 14 | } 15 | 16 | const JWT_SECRET = "appsecret123"; 17 | 18 | const users: { 19 | id: string; 20 | name: string; 21 | email: string; 22 | password: string; 23 | }[] = []; 24 | 25 | function validPassword(user, password) { 26 | return bcrypt.compareSync(password, user.password); 27 | } 28 | 29 | export const resolvers = { 30 | Query: { 31 | async me(_parent, _args, ctx: Context, _info) { 32 | const { token } = cookie.parse(ctx.req.headers.cookie ?? ""); 33 | 34 | if (token) { 35 | try { 36 | const { id, email } = jwt.verify(token, JWT_SECRET); 37 | return await ctx.prisma.user.findOne({ where: { id } }); 38 | } catch {} 39 | } 40 | }, 41 | async user(_parent, args: { email: string }, ctx: Context, _info) { 42 | return await ctx.prisma.user.findOne({ where: { email: args.email } }); 43 | }, 44 | async users(_parent, _args, ctx: Context, _info) { 45 | return ctx.prisma.user.findMany({ orderBy: { createdAt: "desc" } }); 46 | }, 47 | }, 48 | 49 | Mutation: { 50 | async createPost( 51 | _parent, 52 | args: { title: string }, 53 | ctx: Context, 54 | _info 55 | ): Promise { 56 | const { token } = cookie.parse(ctx.req.headers.cookie ?? ""); 57 | 58 | if (token) { 59 | try { 60 | const { id, email } = jwt.verify(token, JWT_SECRET); 61 | 62 | return await ctx.prisma.post.create({ 63 | data: { 64 | title: args.title, 65 | user: { 66 | connect: { 67 | id, 68 | }, 69 | }, 70 | }, 71 | }); 72 | } catch { 73 | throw new AuthenticationError( 74 | "Authentication token is invalid, please log in" 75 | ); 76 | } 77 | } 78 | }, 79 | async signup(_parent, args, ctx: Context, _info): Promise { 80 | const salt = bcrypt.genSaltSync(); 81 | 82 | const user = await ctx.prisma.user.create({ 83 | data: { 84 | email: args.email, 85 | name: args.name, 86 | password: bcrypt.hashSync(args.password, salt), 87 | }, 88 | }); 89 | const token = jwt.sign( 90 | { email: user.email, id: user.id, time: new Date() }, 91 | JWT_SECRET, 92 | { 93 | expiresIn: "6h", 94 | } 95 | ); 96 | 97 | ctx.res.setHeader( 98 | "Set-Cookie", 99 | cookie.serialize("token", token, { 100 | httpOnly: true, 101 | maxAge: 6 * 60 * 60, 102 | path: "/", 103 | sameSite: "lax", 104 | secure: process.env.NODE_ENV === "production", 105 | }) 106 | ); 107 | 108 | return user; 109 | }, 110 | 111 | async login( 112 | _parent, 113 | args: { email: string; password: string }, 114 | ctx: Context, 115 | _info 116 | ) { 117 | const user = await ctx.prisma.user.findOne({ 118 | where: { email: args.email }, 119 | }); 120 | 121 | if (user && validPassword(user, args.password)) { 122 | const token = jwt.sign( 123 | { email: user.email, id: user.id, time: new Date() }, 124 | JWT_SECRET, 125 | { 126 | expiresIn: "6h", 127 | } 128 | ); 129 | 130 | ctx.res.setHeader( 131 | "Set-Cookie", 132 | cookie.serialize("token", token, { 133 | httpOnly: true, 134 | maxAge: 6 * 60 * 60, 135 | path: "/", 136 | sameSite: "lax", 137 | secure: process.env.NODE_ENV === "production", 138 | }) 139 | ); 140 | 141 | return user; 142 | } 143 | 144 | throw new UserInputError("Invalid email and password combination"); 145 | }, 146 | async signOut(_parent, _args, ctx: Context, _info) { 147 | ctx.res.setHeader( 148 | "Set-Cookie", 149 | cookie.serialize("token", "", { 150 | httpOnly: true, 151 | maxAge: -1, 152 | path: "/", 153 | sameSite: "lax", 154 | secure: process.env.NODE_ENV === "production", 155 | }) 156 | ); 157 | 158 | return true; 159 | }, 160 | }, 161 | User: { 162 | async posts({ id }, _args, ctx: Context): Promise { 163 | return await ctx.prisma.post.findMany({ 164 | where: { user: { id } }, 165 | orderBy: { createdAt: "desc" }, 166 | }); 167 | }, 168 | }, 169 | }; 170 | -------------------------------------------------------------------------------- /apollo/client.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Head from "next/head"; 3 | import { ApolloProvider } from "@apollo/react-hooks"; 4 | import { ApolloClient } from "apollo-client"; 5 | import { InMemoryCache } from "apollo-cache-inmemory"; 6 | import { onError } from "apollo-link-error"; 7 | 8 | let globalApolloClient = null; 9 | 10 | /** 11 | * Creates and provides the apolloContext 12 | * to a next.js PageTree. Use it by wrapping 13 | * your PageComponent via HOC pattern. 14 | * @param {Function|Class} PageComponent 15 | * @param {Object} [config] 16 | * @param {Boolean} [config.ssr=true] 17 | */ 18 | export function withApollo(PageComponent, { ssr = true } = {}) { 19 | const WithApollo = ({ apolloClient, apolloState, ...pageProps }) => { 20 | const client = apolloClient || initApolloClient(undefined, apolloState); 21 | return ( 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | // Set the correct displayName in development 29 | if (process.env.NODE_ENV !== "production") { 30 | const displayName = 31 | PageComponent.displayName || PageComponent.name || "Component"; 32 | 33 | if (displayName === "App") { 34 | console.warn("This withApollo HOC only works with PageComponents."); 35 | } 36 | 37 | WithApollo.displayName = `withApollo(${displayName})`; 38 | } 39 | 40 | if (ssr || PageComponent.getInitialProps) { 41 | WithApollo.getInitialProps = async (ctx) => { 42 | const { AppTree } = ctx; 43 | 44 | // Initialize ApolloClient, add it to the ctx object so 45 | // we can use it in `PageComponent.getInitialProp`. 46 | const apolloClient = (ctx.apolloClient = initApolloClient({ 47 | res: ctx.res, 48 | req: ctx.req, 49 | })); 50 | 51 | // Run wrapped getInitialProps methods 52 | let pageProps = {}; 53 | if (PageComponent.getInitialProps) { 54 | pageProps = await PageComponent.getInitialProps(ctx); 55 | } 56 | 57 | // Only on the server: 58 | if (typeof window === "undefined") { 59 | // When redirecting, the response is finished. 60 | // No point in continuing to render 61 | if (ctx.res && ctx.res.finished) { 62 | return pageProps; 63 | } 64 | 65 | // Only if ssr is enabled 66 | if (ssr) { 67 | try { 68 | // Run all GraphQL queries 69 | const { getDataFromTree } = await import("@apollo/react-ssr"); 70 | await getDataFromTree( 71 | 77 | ); 78 | } catch (error) { 79 | // Prevent Apollo Client GraphQL errors from crashing SSR. 80 | // Handle them in components via the data.error prop: 81 | // https://www.apollographql.com/docs/react/api/react-apollo.html#graphql-query-data-error 82 | console.error("Error while running `getDataFromTree`", error); 83 | } 84 | 85 | // getDataFromTree does not call componentWillUnmount 86 | // head side effect therefore need to be cleared manually 87 | Head.rewind(); 88 | } 89 | } 90 | 91 | // Extract query data from the Apollo store 92 | const apolloState = apolloClient.cache.extract(); 93 | 94 | return { 95 | ...pageProps, 96 | apolloState, 97 | }; 98 | }; 99 | } 100 | 101 | return WithApollo; 102 | } 103 | 104 | /** 105 | * Always creates a new apollo client on the server 106 | * Creates or reuses apollo client in the browser. 107 | * @param {Object} initialState 108 | */ 109 | function initApolloClient(ctx, initialState) { 110 | // Make sure to create a new client for every server-side request so that data 111 | // isn't shared between connections (which would be bad) 112 | if (typeof window === "undefined") { 113 | return createApolloClient(ctx, initialState); 114 | } 115 | 116 | // Reuse client on the client-side 117 | if (!globalApolloClient) { 118 | globalApolloClient = createApolloClient(ctx, initialState); 119 | } 120 | 121 | return globalApolloClient; 122 | } 123 | 124 | /** 125 | * Creates and configures the ApolloClient 126 | * @param {Object} [initialState={}] 127 | */ 128 | function createApolloClient(ctx = {}, initialState = {}) { 129 | const ssrMode = typeof window === "undefined"; 130 | const cache = new InMemoryCache().restore(initialState); 131 | 132 | // Check out https://github.com/zeit/next.js/pull/4611 if you want to use the AWSAppSyncClient 133 | return new ApolloClient({ 134 | ssrMode, 135 | link: createIsomorphLink(ctx), 136 | cache, 137 | }); 138 | } 139 | 140 | function createIsomorphLink(ctx) { 141 | if (typeof window === "undefined") { 142 | const { SchemaLink } = require("apollo-link-schema"); 143 | const { schema } = require("./schema"); 144 | return new SchemaLink({ schema, context: ctx }); 145 | } else { 146 | const { HttpLink } = require("apollo-link-http"); 147 | const errorLink = onError(({ graphQLErrors, networkError }) => { 148 | if (process.env.NODE_ENV !== "development") return; 149 | if (graphQLErrors) 150 | graphQLErrors.map(({ message, locations, path }) => 151 | console.log( 152 | `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}` 153 | ) 154 | ); 155 | 156 | if (networkError) console.log(`[Network error]: ${networkError}`); 157 | }); 158 | 159 | return errorLink.concat( 160 | new HttpLink({ 161 | uri: "/api/graphql", 162 | credentials: "same-origin", 163 | }) 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Fullstack Boilerplate 2 | 3 | A monorepo web application boilerplate with a graphQL API, server-side cookie authentication with bcrypt and [jwt](https://jwt.io), database access with [Prisma 2](https://prisma.io), and styling with [Ant Design](https://ant.design). 4 | 5 | ![Demo GIF](public/demo.gif) 6 | 7 | # But Why 8 | 9 | When building a new project, choosing a technology stack, configuring it, wiring it all together, and figuring out how to dpeloy it properly tends to take far more time that building and shipping features (the important _and_ enjoyable part). This boilerplate starts you off with an app that already works, so you can get right to the good stuff. 10 | 11 | # Features 12 | 13 | ⚡️ Deploy a full-featured production-ready web application in less than 60 seconds. 14 | 15 | 🔐 Allow users to sign up and log in with an email and password, view their profiles and data, and log out. Outputs feedback for loading and errors states to enhance UX. 16 | 17 | 📃 Includes a splash page, login page, sign up page, and dashboard. 18 | 19 | 🤖‍‍ Includes wired up forms, queries, mutations, snackbars, and more commonly used components. 20 | 21 | ☁️ [Zero Config Deployments](https://zeit.co/blog/zero-config). It just works 🔥 22 | 23 | 24 | # Tech stack 25 | 26 | 🤖 [Typescript](https://www.typescriptlang.org) - static types, used throughout the client and server (especially handy for the auto-generated prisma2 client). 27 | 28 | 🌚 [Next.js](https://github.com/zeit/next.js) - server-side rendering, file-based routing in the `pages` directory, and serverless build of of graphql API within `pages/api/graphql.ts` using [API Routes](https://github.com/zeit/next.js#api-routes). 29 | 30 | 🦋 [Apollo](https://www.apollographql.com/docs/react/hooks-migration/) (React Hooks API) - GraphQL client for queries and mutations. 31 | 32 | 🦄 [Prisma](https://prisma.io) - Next-generation database access and migration tools. 33 | 34 | 💅 [Ant Design](https:/ant.design) - Beautiful, responsive, easy-to-use components. 35 | 36 | 🚀[Render](https://render.com) - app and postgres deployment. 37 | 38 | # Quick Start 39 | 40 | Clone the repository 41 | 42 | ```bash 43 | git clone https://github.com/kunalgorithm/graphql-fullstack 44 | ``` 45 | 46 | install dependencies, then run the development server: 47 | 48 | ```bash 49 | yarn 50 | yarn dev 51 | ``` 52 | 53 | 54 | # Development 55 | 56 | ## Create new data types 57 | 58 | Create a new project and install the prisma CLI, along with typescript, as development dependencies 59 | 60 | ``` 61 | npm init -y 62 | yarn add -D @prisma/cli typescript ts-node @types/node 63 | ``` 64 | 65 | You can now invoke the prisma CLI 66 | 67 | ``` 68 | npx prisma 69 | ``` 70 | 71 | Then, open `schema.prisma` in the `prisma` directory and add the following 72 | 73 | ```prisma 74 | datasource sqlite { 75 | provider = "sqlite" 76 | url = "file:./dev.db" 77 | } 78 | 79 | generator client { 80 | provider = "prisma-client-js" 81 | binaryTargets = ["native"] 82 | } 83 | 84 | model User { 85 | id Int @id @default(autoincrement()) 86 | createdAt DateTime @default(now()) 87 | email String @unique 88 | name String? 89 | password String 90 | 91 | } 92 | 93 | ``` 94 | 95 | ## Migrate your database 96 | 97 | `yarn migrate:save` 98 | 99 | and add a name, perhaps simply "init", to save your first database migration. When asked whether to create a SQLite file, select yes. Then, apply the migration by running 100 | 101 | `yarn migrate:up` 102 | 103 | Finally, run 104 | 105 | `yarn generate` 106 | 107 | to generate the prisma client to reflect the new changes. 108 | 109 | ### Adding a new field 110 | 111 | Open `schema.prisma` in the `prisma` directory. Add a new optional field, _githubUrl_ to a data type, _User_. 112 | 113 | ```prisma 114 | model User { 115 | id Int @id @default(autoincrement()) 116 | createdAt DateTime @default(now()) 117 | email String @unique 118 | name String? 119 | password String 120 | githubUrl String? 121 | } 122 | ``` 123 | 124 | > Note: the `?` signals that the field is optional. 125 | 126 | ### Make it available to the frontend. 127 | 128 | Now that you've added a new field to your database and made it available to the _server_, you need to make it available to your _client_ by defining it within the graphQL endpoint's type definitions. 129 | 130 | Open the API type defintion file at `apollo/typedefs.js` and extend 131 | 132 | ```diff 133 | type User { 134 | id: ID! 135 | email: String! 136 | name: String 137 | password: String! 138 | posts: [Post!]! 139 | + graphqlUrl: String 140 | } 141 | ``` 142 | 143 | Now, render the new data on the app by adding it to the query on the `Profile` component 144 | 145 | ```diff 146 | const { loading, error, data, client } = useQuery( 147 | gql` 148 | query { 149 | me { 150 | id 151 | name 152 | email 153 | + githubUrl 154 | } 155 | } 156 | ` 157 | ); 158 | ``` 159 | 160 | Now, you'll have the `data.me.githubUrl` available to you (with typesafety and auto-completion!) in your react components ✨ 161 | 162 | > NOTE: If you aren't seeing the new field, you may have forgotten to run `yarn migrate` to update the prisma client after migrating the database. 163 | 164 | ## Authentication 165 | 166 | The API sets a server-side cookie with `http: only` enabled on the root of the domain, seen in the `login` and `signup` resolvers: 167 | 168 | ```ts 169 | ctx.res.setHeader( 170 | "Set-Cookie", 171 | cookie.serialize("token", token, { 172 | httpOnly: true, 173 | maxAge: 6 * 60 * 60, // 6 hours 174 | path: "/", 175 | sameSite: "lax", 176 | secure: process.env.NODE_ENV === "production", 177 | }) 178 | ); 179 | ``` 180 | 181 | which is automatically attached to subsequent requests to the server and parsed and accessible via NextJS's API Routes for resolvers that require authenticated users: 182 | 183 | ```ts 184 | const { token } = cookie.parse(ctx.req.headers.cookie ?? ""); 185 | 186 | if (token) { 187 | try { 188 | const { id, email } = jwt.verify(token, JWT_SECRET); 189 | return await ctx.prisma.user.findOne({ where: { id } }); 190 | } catch { 191 | throw new AuthenticationError( 192 | "Authentication token is invalid, please log in" 193 | ); 194 | } 195 | } 196 | ``` 197 | 198 | This solves two problems pervasive in modern javascript applications: 199 | 200 | 1. The cookies cannot be read from client-side javascript, protecting the application from cross-site forgery attacks. 201 | 2. The cookie is attached to requests received by the server automatically, allowing server-side requests from Next to be authenticated without requiring the client to handle and attach the token manually. This not only speeds up data requests, but cleans up the client-side code quite a bit. 202 | 203 | ## Deployment 204 | 205 | This app uses SQLite for local development, which stores application data in a local file. To deploy the platform, however, you'll have to provision a postgres or MySQL database in the cloud for your deployment to connect to. 206 | 207 | You can prepare for this by switching from developing on SQLite locally to a local postgres instance. To do this, change the datasource in `schema.prisma` to 208 | 209 | ```prisma 210 | datasource postgres { 211 | provider = "postgresql" 212 | url = env("DATABASE_URL") 213 | } 214 | ``` 215 | 216 | and set the `DATABASE_URL` as an environment variable in your terminal 217 | 218 | ```bash 219 | export DATABASE_URL=postgresql://johndoe:mypassword@localhost:5432/mydb?schema=public 220 | ``` 221 | 222 | Then, follow the instructions above for _Migrating your database_, then restart your development server on the same terminal and ensure you can read and write data to the new database correctly. 223 | 224 | ### Deploying Render 225 | 226 | You deploy this app and a managed postgres instance on [Render](https://render.com) and connect to it securely with an internal connection string, only useable by applications on the Render platform. 227 | 228 | > Note: that web service starter plan deployments and postgres database starter instances each cost \$7/month on render at the time of writing. 229 | 230 | To deploy on render, just hit 231 | 232 | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/kunalgorithm/fullstack-graphql) 233 | 234 | ## Contributions welcome! 235 | 236 | Feel free to open an issue or submit a pull request 🙂 237 | 238 | ## Need help? 239 | 240 | Send me a DM on twitter! [@kunalgorithm](https://twitter.com/kunalgorithm) 241 | --------------------------------------------------------------------------------