├── 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 |
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 |
46 |
47 |
48 | {/* Sider */}
49 |
50 |
51 |
52 |
53 |
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 |
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 |
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 |
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 | 
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 | [](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 |
--------------------------------------------------------------------------------