├── .circleci └── config.yml ├── .gitignore ├── README.md ├── __tests__ ├── about.js └── index.js ├── babel.config.js ├── components ├── ErrorMessage │ ├── index.js │ └── styles.js ├── Header │ ├── index.js │ └── styles.js ├── PostList │ ├── index.js │ └── styles.js ├── PostUpvoter │ ├── index.js │ └── styles.js └── Submit │ ├── index.js │ └── styles.js ├── jest.config.js ├── lib ├── apollo.js └── layout.js ├── package-lock.json ├── package.json ├── pages ├── about.js └── index.js ├── vercel.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/CircleCI-Public/circleci-demo-javascript-express/blob/master/.circleci/config.yml 2 | version: 2.1 3 | jobs: 4 | build: 5 | docker: 6 | - image: circleci/node:11 7 | steps: 8 | - checkout 9 | - run: 10 | name: update-npm 11 | command: 'sudo npm install -g npm@6' 12 | - restore_cache: 13 | key: dependency-cache-{{ checksum "package-lock.json" }} 14 | - run: 15 | name: install-npm-wee 16 | command: npm install 17 | - save_cache: 18 | key: dependency-cache-{{ checksum "package-lock.json" }} 19 | paths: 20 | - ./node_modules 21 | - run: 22 | name: test 23 | command: npm test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | node_modules 3 | *~ 4 | .next 5 | npm-debug.log 6 | 7 | .now -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next & Apollo Example [![Build Status](https://travis-ci.org/adamsoffer/next-apollo-example.svg?branch=master)](https://travis-ci.org/adamsoffer/next-apollo-example) 2 | 3 | This example utilizes the [next-apollo](https://www.npmjs.com/package/next-apollo) package which is ideal if you want to tuck away some of the ceremony involved when using Apollo in your Next.js app. It also features my preferred CSS-in-JS solution, [Emotion](https://emotion.sh/). 4 | 5 | [Demo](https://next-with-apollo.vercel.app/) 6 | 7 | ## How to use 8 | 9 | Install it and run 10 | 11 | ```bash 12 | npm install 13 | npm run dev 14 | ``` 15 | 16 | Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download)) 17 | 18 | ```bash 19 | now 20 | ``` 21 | 22 | ## The idea behind the example 23 | 24 | Apollo is a GraphQL client that allows you to easily query the exact data you need from a GraphQL server. In addition to fetching and mutating data, Apollo analyzes your queries and their results to construct a client-side cache of your data, which is kept up to date as further queries and mutations are run, fetching more results from the server. 25 | 26 | In this simple example, we integrate Apollo seamlessly with Next by wrapping our _pages_ inside a [higher-order component (HOC)](https://facebook.github.io/react/docs/higher-order-components.html). Using the HOC pattern we're able to pass down a central store of query result data created by Apollo into our React component hierarchy defined inside each page of our Next application. 27 | 28 | On initial page load, while on the server and inside `getInitialProps`, we invoke the Apollo method, [`getDataFromTree`](http://dev.apollodata.com/react/server-side-rendering.html#getDataFromTree). This method returns a promise; at the point in which the promise resolves, our Apollo Client store is completely initialized. 29 | 30 | This example relies on [Prisma + Nexus](https://github.com/prisma-labs/nextjs-graphql-api-examples) for its GraphQL backend. 31 | -------------------------------------------------------------------------------- /__tests__/about.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from "react"; 3 | import { act, render } from "@testing-library/react"; 4 | import { RouterContext } from "next/dist/next-server/lib/router-context"; 5 | import About from "../pages/about"; 6 | 7 | it('says "we integrate Apollo seamlessly with Next"', async () => { 8 | const router = { 9 | pathname: "/about", 10 | route: "/about", 11 | query: {}, 12 | asPath: "/about" 13 | }; 14 | 15 | let container; 16 | await act(async () => { 17 | container = render( 18 | 19 | 20 | 21 | ).container; 22 | }); 23 | 24 | expect(container.textContent).toMatch( 25 | /we integrate Apollo seamlessly with Next/ 26 | ); 27 | }); 28 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | import React from "react"; 3 | import { act, render, waitForElement } from "@testing-library/react"; 4 | import { RouterContext } from "next/dist/next-server/lib/router-context"; 5 | import Index from "../pages/index"; 6 | 7 | const routerValue = { 8 | pathname: "/", 9 | route: "/", 10 | query: {}, 11 | asPath: "/" 12 | }; 13 | 14 | it("renders attribution footnote", async () => { 15 | let container; 16 | await act(async () => { 17 | container = render( 18 | 19 | 20 | 21 | ).container; 22 | }); 23 | 24 | expect(container.textContent).toMatch(/Made by @adamSoffer/); 25 | }); 26 | 27 | it("renders some posts from the query", async () => { 28 | let findAllByTestId; 29 | await act(async () => { 30 | const renderResult = render( 31 | 32 | 33 | 34 | ); 35 | 36 | findAllByTestId = renderResult.findAllByTestId; 37 | }); 38 | 39 | const listEntries = await waitForElement(() => 40 | findAllByTestId("postListListItem") 41 | ); 42 | 43 | expect(listEntries.length).toBeGreaterThan(0); 44 | }); 45 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["next/babel"] 3 | }; 4 | -------------------------------------------------------------------------------- /components/ErrorMessage/index.js: -------------------------------------------------------------------------------- 1 | import { Container } from './styles' 2 | export default ({ message }) => {message} 3 | -------------------------------------------------------------------------------- /components/ErrorMessage/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const Container = styled("div")({ 4 | padding: "20px", 5 | fontSize: "14px", 6 | color: "white", 7 | backgroundColor: "red" 8 | }); 9 | -------------------------------------------------------------------------------- /components/Header/index.js: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { useRouter } from "next/router"; 3 | import { Container, LinkText } from "./styles"; 4 | 5 | const Header = () => { 6 | const router = useRouter(); 7 | const { pathname } = router; 8 | return ( 9 | 10 | 11 | 12 | Home 13 | 14 | 15 | 16 | 17 | 18 | About 19 | 20 | 21 | 22 | 27 | Github 28 | 29 | 30 | ); 31 | 32 | // const { pathname } = router 33 | // return ( 34 | // 35 | // 36 | // 37 | // Home 38 | // 39 | // 40 | 41 | // 42 | // 43 | // 44 | // About 45 | // 46 | // 47 | // 48 | 49 | // ) 50 | }; 51 | 52 | export default Header; 53 | -------------------------------------------------------------------------------- /components/Header/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const Container = styled("header")({ 4 | marginBottom: "25px" 5 | }); 6 | 7 | export const LinkText = styled.span(props => ({ 8 | fontSize: "14px", 9 | marginRight: "15px", 10 | textDecoration: props.isActive ? "underline" : "none" 11 | })); 12 | -------------------------------------------------------------------------------- /components/PostList/index.js: -------------------------------------------------------------------------------- 1 | import { gql, useQuery } from "@apollo/client"; 2 | import PostUpvoter from "../PostUpvoter"; 3 | 4 | import { 5 | Container, 6 | List, 7 | ListItem, 8 | ListItemContainer, 9 | Num, 10 | A, 11 | Button 12 | } from "./styles"; 13 | 14 | const POSTS_PER_PAGE = 10; 15 | 16 | const GET_POSTS = gql` 17 | query allPosts($first: Int!, $skip: Int!) { 18 | allPosts(orderBy: { createdAt: desc }, first: $first, skip: $skip) { 19 | id 20 | title 21 | votes 22 | url 23 | createdAt 24 | } 25 | _allPostsMeta { 26 | count 27 | } 28 | } 29 | `; 30 | 31 | function PostList() { 32 | const { loading, error, data, fetchMore } = useQuery(GET_POSTS, { 33 | variables: { skip: 0, first: POSTS_PER_PAGE }, 34 | notifyOnNetworkStatusChange: true 35 | }); 36 | if (data && data.allPosts) { 37 | const areMorePosts = data.allPosts.length < data._allPostsMeta.count; 38 | return ( 39 | 40 | 41 | {data.allPosts.map((post, index) => ( 42 | 43 | 44 | {index + 1}. 45 | {post.title} 46 | 47 | 48 | 49 | ))} 50 | 51 | {areMorePosts ? ( 52 | 55 | ) : ( 56 | "" 57 | )} 58 | 59 | ); 60 | } 61 | return
Loading...
; 62 | } 63 | 64 | function loadMorePosts(data, fetchMore) { 65 | return fetchMore({ 66 | variables: { 67 | skip: data.allPosts.length 68 | }, 69 | updateQuery: (previousResult, { fetchMoreResult }) => { 70 | if (!fetchMoreResult) { 71 | return previousResult; 72 | } 73 | return Object.assign({}, previousResult, { 74 | // Append the new posts results to the old one 75 | allPosts: [...previousResult.allPosts, ...fetchMoreResult.allPosts] 76 | }); 77 | } 78 | }); 79 | } 80 | 81 | export default PostList; 82 | -------------------------------------------------------------------------------- /components/PostList/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const Container = styled("section")({ 4 | paddingBottom: "20px" 5 | }); 6 | 7 | export const List = styled("ul")({ 8 | margin: 0, 9 | padding: 0 10 | }); 11 | 12 | export const ListItem = styled("li")({ 13 | display: "block", 14 | marginBottom: "10px" 15 | }); 16 | 17 | export const ListItemContainer = styled("div")({ 18 | alignItems: "center", 19 | display: "flex" 20 | }); 21 | 22 | export const Num = styled("span")({ 23 | fontSize: "14px", 24 | marginRight: "5px" 25 | }); 26 | 27 | export const A = styled("a")({ 28 | fontSize: "14px", 29 | marginRight: "10px", 30 | textDecoration: "none", 31 | paddingBottom: 0, 32 | border: 0 33 | }); 34 | 35 | export const Button = styled("button")({ 36 | ":before": { 37 | alignSelf: "center", 38 | borderColor: "#ffffff transparent transparent transparent", 39 | borderStyle: "solid", 40 | borderWidth: "6px 4px 0 4px", 41 | content: '""', 42 | height: 0, 43 | marginRight: "5px", 44 | width: 0 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /components/PostUpvoter/index.js: -------------------------------------------------------------------------------- 1 | import { gql, useMutation } from "@apollo/client"; 2 | import { Button } from "./styles"; 3 | 4 | const UPDATE_POST = gql` 5 | mutation votePost($id: String!) { 6 | votePost(id: $id) { 7 | id 8 | votes 9 | __typename 10 | } 11 | } 12 | `; 13 | 14 | export default function PostUpvoter({ id, votes }) { 15 | const [updatePost, { error, data }] = useMutation(UPDATE_POST, { 16 | variables: { id, votes: votes + 1 }, 17 | optimisticResponse: { 18 | __typename: "Mutation", 19 | votePost: { 20 | __typename: "Post", 21 | id, 22 | votes: votes + 1 23 | } 24 | } 25 | }); 26 | return ; 27 | } 28 | -------------------------------------------------------------------------------- /components/PostUpvoter/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const Button = styled("button")({ 4 | backgroundColor: "transparent", 5 | border: "1px solid #e4e4e4", 6 | color: "#000", 7 | ":active": { 8 | backgroundColor: "transparent" 9 | }, 10 | ":before": { 11 | alignSelf: "center", 12 | borderColor: "transparent transparent #000000 transparent", 13 | borderStyle: "solid", 14 | borderWidth: "0 4px 6px 4px", 15 | content: '""', 16 | height: 0, 17 | marginRight: "5px", 18 | width: 0 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /components/Submit/index.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { gql, useMutation } from "@apollo/client"; 3 | import { Form, H1, Input } from "./styles"; 4 | 5 | const CREATE_POST = gql` 6 | mutation createPost($title: String!, $url: String!) { 7 | createPost(title: $title, url: $url) { 8 | id 9 | title 10 | votes 11 | url 12 | createdAt 13 | } 14 | } 15 | `; 16 | 17 | const GET_POSTS = gql` 18 | query allPosts($first: Int!, $skip: Int!) { 19 | allPosts(orderBy: { createdAt: desc }, first: $first, skip: $skip) { 20 | id 21 | title 22 | votes 23 | url 24 | createdAt 25 | } 26 | } 27 | `; 28 | 29 | export default function Submit() { 30 | const [title, setTitle] = useState(""); 31 | const [url, setUrl] = useState(""); 32 | 33 | const [createPost, { error, data }] = useMutation(CREATE_POST, { 34 | variables: { title, url }, 35 | update: (proxy, mutationResult) => { 36 | const { allPosts } = proxy.readQuery({ 37 | query: GET_POSTS, 38 | variables: { first: 10, skip: 0 } 39 | }); 40 | const newPost = mutationResult.data.createPost; 41 | proxy.writeQuery({ 42 | query: GET_POSTS, 43 | variables: { first: 10, skip: 0 }, 44 | data: { 45 | allPosts: [newPost, ...allPosts] 46 | } 47 | }); 48 | } 49 | }); 50 | 51 | function handleSubmit(e) { 52 | e.preventDefault(); 53 | if (title === "" || url === "") { 54 | window.alert("Both fields are required."); 55 | return false; 56 | } 57 | 58 | createPost(); 59 | 60 | // reset form 61 | e.target.elements.title.value = ""; 62 | e.target.elements.url.value = ""; 63 | } 64 | 65 | // prepend http if missing from url 66 | const pattern = /^((http|https):\/\/)/; 67 | 68 | return ( 69 |
70 |

Submit

71 | setTitle(e.target.value)} 75 | /> 76 | 80 | setUrl( 81 | !pattern.test(e.target.value) 82 | ? `https://${e.target.value}` 83 | : e.target.value 84 | ) 85 | } 86 | /> 87 | 88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /components/Submit/styles.js: -------------------------------------------------------------------------------- 1 | import styled from "@emotion/styled"; 2 | 3 | export const Form = styled("form")({ 4 | borderBottom: "1px solid #ececec", 5 | paddingBottom: "20px", 6 | marginBottom: "20px" 7 | }); 8 | 9 | export const H1 = styled("h1")({ 10 | fontSize: "20px" 11 | }); 12 | 13 | export const Input = styled("input")({ 14 | display: "block", 15 | marginBottom: "10px" 16 | }); 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testPathIgnorePatterns: ["/.next/", "/node_modules/"] 3 | }; 4 | -------------------------------------------------------------------------------- /lib/apollo.js: -------------------------------------------------------------------------------- 1 | import { withApollo } from "next-apollo"; 2 | import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client"; 3 | 4 | const apolloClient = new ApolloClient({ 5 | ssrMode: typeof window === "undefined", 6 | link: new HttpLink({ 7 | uri: "https://nextjs-graphql-with-prisma-simple.vercel.app/api", // Server URL (must be absolute) 8 | credentials: "same-origin" // Additional fetch() options like `credentials` or `headers` 9 | }), 10 | cache: new InMemoryCache() 11 | }); 12 | 13 | export default withApollo(apolloClient); 14 | -------------------------------------------------------------------------------- /lib/layout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Global, css } from "@emotion/core"; 3 | 4 | const Layout = ({ children }) => ( 5 |
6 |
7 | 64 | {children} 65 | 68 |
69 |
70 | ); 71 | 72 | export default Layout; 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-apollo-example", 3 | "url": "git@github.com:adamsoffer/next-apollo-example.git", 4 | "version": "0.0.0", 5 | "description": "Next + Apollo integration", 6 | "scripts": { 7 | "dev": "next", 8 | "build": "next build", 9 | "start": "next start", 10 | "precommit": "lint-staged", 11 | "test": "jest" 12 | }, 13 | "author": "Adam Soffer", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@babel/preset-env": "^7.9.6", 17 | "@babel/preset-react": "^7.9.4", 18 | "@testing-library/react": "^9.1.4", 19 | "babel-jest": "^24.9.0", 20 | "babel-plugin-emotion": "^10.0.13", 21 | "husky": "^1.3.1", 22 | "jest": "^24.9.0", 23 | "lint-staged": "^8.2.0", 24 | "prettier": "^1.18.2", 25 | "react-test-renderer": "^16.9.0" 26 | }, 27 | "dependencies": { 28 | "@apollo/client": "^3.2.5", 29 | "@emotion/core": "^10.0.28", 30 | "@emotion/styled": "^10.0.27", 31 | "graphql": "^15.3.0", 32 | "next": "^9.5.5", 33 | "next-apollo": "^5.0.4", 34 | "prop-types": "^15.6.1", 35 | "react": "^17.0.1", 36 | "react-dom": "^17.0.1" 37 | }, 38 | "husky": { 39 | "hooks": { 40 | "pre-commit": "lint-staged" 41 | } 42 | }, 43 | "lint-staged": { 44 | "*.{js,ts,tsx,json,css,md}": [ 45 | "prettier --write", 46 | "git add" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pages/about.js: -------------------------------------------------------------------------------- 1 | import Main from '../lib/layout' 2 | import Header from '../components/Header' 3 | 4 | export default props => ( 5 |
6 |
7 |
8 |

The Idea Behind This Example

9 |

10 | Apollo is a GraphQL client that 11 | allows you to easily query the exact data you need from a GraphQL 12 | server. In addition to fetching and mutating data, Apollo analyzes your 13 | queries and their results to construct a client-side cache of your data, 14 | which is kept up to date as further queries and mutations are run, 15 | fetching more results from the server. 16 |

17 |

18 | In this simple example, we integrate Apollo seamlessly with{' '} 19 | Next by wrapping our pages 20 | inside a{' '} 21 | 22 | higher-order component (HOC) 23 | . Using the HOC pattern we're able to pass down a central store of 24 | query result data created by Apollo into our React component hierarchy 25 | defined inside each page of our Next application. 26 |

27 |

28 | On initial page load, while on the server and inside getInitialProps, we 29 | invoke the Apollo method,{' '} 30 | 31 | getDataFromTree 32 | . This method returns a promise; at the point in which the promise 33 | resolves, our Apollo Client store is completely initialized. 34 |

35 |

36 | This example relies on graph.cool for 37 | its GraphQL backend. 38 |

39 |
40 |
41 | ) 42 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Main from "../lib/layout"; 2 | import Header from "../components/Header"; 3 | import Submit from "../components/Submit"; 4 | import PostList from "../components/PostList"; 5 | import withApollo from "../lib/apollo"; 6 | 7 | const Home = props => { 8 | return ( 9 |
10 |
11 | 12 | 13 |
14 | ); 15 | }; 16 | 17 | export default withApollo({ ssr: true })(Home); 18 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2 3 | } 4 | --------------------------------------------------------------------------------