├── .babelrc ├── .dockerignore ├── .env ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── components ├── Layout.js └── Navigation.js ├── lib └── contentUtils.js ├── package-lock.json ├── package.json ├── pages ├── about.js ├── activity.js └── index.js ├── server.js └── static └── images ├── favicon.ico └── react-rauma.png /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "env": { 6 | "development": { 7 | "plugins": ["inline-dotenv"] 8 | }, 9 | "production": { 10 | "plugins": ["transform-inline-environment-variables"] 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | API_ROOT=https://v1-11-hbgl5gq-oiiukjqgkij7e.eu.platform.sh 2 | ROOT_LOCATION=68 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | /node_modules/ 3 | /.next/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine 2 | 3 | # Create app directory 4 | RUN mkdir -p /usr/src/app 5 | WORKDIR /usr/src/app 6 | 7 | # Install app dependencies 8 | COPY package.json /usr/src/app/ 9 | RUN npm install 10 | 11 | # Bundle app source 12 | COPY . /usr/src/app 13 | RUN npm run build 14 | 15 | EXPOSE 3000 16 | 17 | CMD [ "npm", "start", "next" ] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Jani Tarvainen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Decoupled CMS example with GraphQL and Next.js 3 | 4 | This repository contains the source code for the sample application from the talk "Easy decoupled sitebuilding with GraphQL and Next.js" held in September 2017 at Drupalcon Vienna and Helsinki.js Meetup. 5 | 6 | The application is an example of a front end implementation decoupled from the CMS backend used for content storage only. This example uses a hosted eZ Platform instance with GraphQL enabled using the GraphQL Bundle. This API is consumed by a Next.js frontend that uses React.js for templating and handles Server Side Rendering (SSR), etc. boilerplate. 7 | 8 | The application is online at https://react.nu/ slides for the presentation are available online here: https://janit.iki.fi/cms-graphql-nextjs 9 | 10 | 11 | ## Installation 12 | 13 | The application can be ran either using NPM scripts in development and production mode, as per documentation of Next.js. 14 | 15 | If you've got a recent Node.js and NPM versions installed, you should be able to do this for development mode: 16 | 17 | ``` 18 | $ npm i 19 | $ npm run dev 20 | ``` 21 | 22 | After that the app is available in http://localhost:3000/ . The first click is server generated, but subsequent pageloads are done by the browser using the GraphQL. 23 | 24 | For production mode you'll need to perform a build and then serve: 25 | 26 | ``` 27 | $ npm i 28 | $ npm run build 29 | $ npm run start 30 | ``` 31 | ### Docker deployment 32 | 33 | Alternatively you can use Docker to host the application. Once you have Docker installed, perform the build from the Dockerfile and then run the image: 34 | 35 | ``` 36 | docker build -t react_rauma . 37 | docker run -p 3000:3000 -d --name=react_rauma --restart=always react_rauma 38 | ``` 39 | 40 | This will make the container run and restart the upon crash or a reboot. The app is again running in port 3000 on localhost. 41 | 42 | ## Application description 43 | 44 | ### Simple static page 45 | 46 | The application uses a standard Next.js structure, with entrypoints in the pages-directory. 47 | 48 | The most simple example is the about page, which is a stateless React component: 49 | 50 | ```jsx 51 | import Link from 'next/link' 52 | import Head from 'next/head' 53 | import Layout from '../components/Layout' 54 | 55 | export default () => ( 56 | 57 | 58 | Tietoa maailman suurimmasta suomenkielisestä React.js-konferenssista 59 | 60 |
61 |

Maailman suurin suomalainen React.js-konferenssi

62 |

Onks tää joku vitsi?! No tavallaan.

63 |
64 | 67 |
68 | ); 69 | ``` 70 | 71 | The above file in `pages/about.js` automatically maps to the address http://localhost:3000/about 72 | 73 | Note the `Link` component which can be used to provide navigation from page to page in your application. 74 | 75 | ## Serving the front page from eZ Platform GraphQL API 76 | 77 | The eZ Platform repository has a tree structure of content, which we will get the parent page and some properties from the child location "activity" objects for navigation with a single GraphQL query: 78 | 79 | - Content 80 | - frontpage 81 | - fields 82 | - title 83 | - body 84 | - main_image 85 | - activities 86 | - activity 1 87 | - id 88 | - name 89 | - activity 2 90 | - id 91 | - name 92 | - activity 3 93 | - id 94 | - name 95 | - ... 96 | 97 | 98 | To get content from the eZ Platform repository using the GraphQL API we need a class that has the `getInitialProps` method that will perform a GraphQL query (in pages/index.js). 99 | 100 | The GraphQL query in the call is using eZ Platform content repository Public API via the GraphQL Bundle. The `rootLocationId` is a configuration value set in `.env`: 101 | 102 | ```jsx 103 | static async getInitialProps() { 104 | let query = ` 105 | 106 | { 107 | frontpage: location(id: ${rootLocationId}) { 108 | content { 109 | fields(identifier: ["title", "body"]) { 110 | fieldDefIdentifier, 111 | value { 112 | ... on TextLineFieldValue { 113 | text 114 | } 115 | ... on RichTextFieldValue { 116 | html5 117 | } 118 | ... on ImageFieldValue { 119 | uri 120 | } 121 | } 122 | } 123 | } 124 | } 125 | activities: locationChildren(id: ${rootLocationId}) { 126 | content { 127 | id 128 | name 129 | } 130 | } 131 | } 132 | 133 | `; 134 | 135 | let variables = { 136 | query: "Search Query", 137 | }; 138 | 139 | return await client.query(query, variables); 140 | } 141 | ``` 142 | 143 | 144 | 145 | 146 | The result is set as `props` to the component and is rendered in the render function: 147 | 148 | ```jsx 149 | render() { 150 | let fields = simplifyFields(this.props.data.frontpage.content.fields); 151 | 152 | return ( 153 | 154 | 155 | {fields.title} 156 | 157 |
158 |

{fields.title}

159 | {Parser(fields.body)} 160 |
161 | 164 |
165 | ); 166 | } 167 | ``` 168 | 169 | Note the use of some helpers to simplify the field structure to ease use, the Parse function to enable HTML output and the Navigation component that could be reused from page to page. 170 | 171 | ## Passing parameters to a view 172 | 173 | To pass parameters to the script to enable URL specific content for URLs, we will need to run a custom server script and pass URL params to it. 174 | 175 | The script is based on the example from the Next.js repository. It only matches URLs with the pattern `/activity/:id` and can be seen below: 176 | 177 | ```jsx 178 | const match = route("/activity/:id"); 179 | 180 | app.prepare().then(() => { 181 | createServer((req, res) => { 182 | const { pathname, query } = parse(req.url, true); 183 | const params = match(pathname); 184 | if (params === false) { 185 | handle(req, res); 186 | return; 187 | } 188 | app.render(req, res, "/activity", Object.assign(params, query)); 189 | }).listen(port, err => { 190 | if (err) throw err; 191 | console.log(`> Ready on http://localhost:${port}`); 192 | }); 193 | }); 194 | ``` 195 | 196 | This script defined to be used via NPM scripts in package JSON when serving the app: 197 | 198 | ```jsx 199 | "scripts": { 200 | "dev": "node server.js", 201 | "build": "next build", 202 | "start": "NODE_ENV=production node server.js" 203 | }, 204 | ``` 205 | 206 | Note that you could use Express, Koa or other Node.js frameworks to process request routing, etc. with Next.js. 207 | 208 | Now that we've got access to URL variables, let's take a look at `pages/activity.js` which is our entrypoint to the view displaying individual activities. 209 | 210 | First off the `getInitialProps` method contains a GraphQL query, but it's using the dynamic variable via the ES6 template literal format (${id}): 211 | 212 | ```jsx 213 | static async getInitialProps({ query: { id } }) { 214 | let query = ` 215 | 216 | { 217 | content(id: ${id}) { 218 | name 219 | fieldDefIdentifier, 220 | fields(identifier: ["title", "body", "main_image"]) { 221 | value { 222 | ... on TextLineFieldValue { 223 | text 224 | } 225 | ... on RichTextFieldValue { 226 | html5 227 | } 228 | ... on ImageFieldValue { 229 | uri 230 | } 231 | } 232 | } 233 | } 234 | } 235 | 236 | `; 237 | 238 | let variables = {}; 239 | 240 | return await client.query(query, variables); 241 | } 242 | 243 | ``` 244 | 245 | Secondly, the `render` method is familiar to the one from `pages/index.js`: 246 | 247 | ```jsx 248 | render() { 249 | let fields = simplifyFields(this.props.data.content.fields); 250 | 251 | return ( 252 | 253 | 254 | {fields.title} - React Rauma 255 | 256 |
257 |

{fields.title}

258 | {Parser(fields.body)} 259 | {fields.main_image ? ( 260 |

261 | 262 |

263 | ) : null} 264 |
265 | 274 |
275 | ); 276 | ``` 277 | 278 | ### Linking to dynamic pages 279 | 280 | In the navigation component we are linking to individual pages. Here you use the Next.js provided `Link` component that will handle the core linking functionality with the `href` attribute. 281 | 282 | What is noteworthy is the `as` attribute that allows aliasing paths to be displayed as `/activity/123` instead of with parameters, e.g. `/activity?id=123`. Otherwise the link generation is inline with standard JSX/Next.js methods: 283 | 284 | ```jsx 285 | 297 | ``` 298 | 299 | Note: In our case the GraphQL backend does not currently expose loading content objects by URL Aliases (e.g. /this/is/my/page), so our URLs end up being in the format `http://example.com/activities/123`, but the `id` parameter could just as well be called something like `slug` or `path` and allow pretty URLs to content views. 300 | -------------------------------------------------------------------------------- /components/Layout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Head from "next/head"; 3 | import Link from "next/link"; 4 | 5 | export default class Layout extends React.Component { 6 | render() { 7 | return ( 8 |
9 | 59 | 60 | 61 | 65 | 70 | 75 | 76 |
77 | 78 | 79 | React Rauma 80 | 81 | 82 |
83 |
{this.props.children}
84 | 92 |
93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /components/Navigation.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | import { simplifyLinks } from "../lib/contentUtils"; 4 | 5 | export default ({ items, title }) => ( 6 | 21 | ); 22 | -------------------------------------------------------------------------------- /lib/contentUtils.js: -------------------------------------------------------------------------------- 1 | function simplifyFields(valueObject) { 2 | var values = new Map(Object.entries(valueObject)); 3 | 4 | let simpleValues = {}; 5 | 6 | values.forEach((item, key, mapObj) => { 7 | let valueKey = Object.keys(item.value)[0]; 8 | simpleValues[item.fieldDefIdentifier] = item.value[valueKey]; 9 | }); 10 | 11 | return simpleValues; 12 | } 13 | 14 | export { simplifyFields }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react_rauma", 3 | "version": "1.0.0", 4 | "description": "React Rauma is the largest React.js conference in Finland. This is is the source of the site.", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "node server.js", 8 | "build": "next build", 9 | "start": "NODE_ENV=production node server.js" 10 | }, 11 | "author": "Jani Tarvainen", 12 | "license": "ISC", 13 | "dependencies": { 14 | "babel-plugin-inline-dotenv": "^1.1.2", 15 | "babel-plugin-transform-inline-environment-variables": "^0.4.3", 16 | "dotenv": "^6.0.0", 17 | "flat": "^4.1.0", 18 | "path-match": "1.2.4", 19 | "graphql-client": "^2.0.0", 20 | "next": "^7.0.0", 21 | "react": "^16.5.0", 22 | "react-dom": "^16.5.0", 23 | "react-render-html": "^0.5.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pages/about.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import Head from 'next/head' 3 | 4 | import Layout from '../components/Layout' 5 | 6 | export default () => ( 7 | 8 | 9 | Tietoa maailman suurimmasta Suomenkielisestä React -konferenssista 10 | 11 |
12 |

Tietoa maailman suurimmasta Suomenkielisestä React -konferenssista

13 |

Onks tää joku vitsi?! No tavallaan.

14 |
15 | 18 |
19 | ); 20 | -------------------------------------------------------------------------------- /pages/activity.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import renderHTML from 'react-render-html'; 3 | 4 | import Link from "next/link"; 5 | import Head from "next/head"; 6 | 7 | import Layout from "../components/Layout"; 8 | import { simplifyFields } from "../lib/contentUtils"; 9 | 10 | let apiRoot = 'https://api.react.nu'; 11 | let rootLocationId = 118; 12 | 13 | var client = require("graphql-client")({ 14 | url: apiRoot + "/graphql/" 15 | }); 16 | 17 | export default class extends React.Component { 18 | static async getInitialProps({ query: { id } }) { 19 | let query = ` 20 | 21 | { 22 | content(id: ${id}) { 23 | name 24 | fields(identifier: ["title", "body", "main_image"]) { 25 | fieldDefIdentifier, 26 | value { 27 | ... on TextLineFieldValue { 28 | text 29 | } 30 | ... on RichTextFieldValue { 31 | html5 32 | } 33 | ... on ImageFieldValue { 34 | uri 35 | } 36 | } 37 | } 38 | } 39 | } 40 | 41 | `; 42 | 43 | let variables = {}; 44 | 45 | return await client.query(query, variables); 46 | } 47 | 48 | render() { 49 | let fields = simplifyFields(this.props.data.content.fields); 50 | 51 | return ( 52 | 53 | 54 | {fields.title} - React Rauma 55 | 56 |
57 |

{fields.title}

58 | {renderHTML(fields.body)} 59 | {fields.main_image ? ( 60 |

61 | 62 |

63 | ) : null} 64 |
65 | 74 |
75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | import Head from "next/head"; 4 | import renderHTML from 'react-render-html'; 5 | 6 | import Layout from "../components/Layout"; 7 | import Navigation from "../components/Navigation"; 8 | import { simplifyFields } from "../lib/contentUtils"; 9 | 10 | let apiRoot = 'https://api.react.nu'; 11 | let rootLocationId = 118; 12 | 13 | var client = require("graphql-client")({ 14 | url: apiRoot + "/graphql/" 15 | }); 16 | 17 | export default class Index extends React.Component { 18 | static async getInitialProps() { 19 | 20 | let query = ` 21 | 22 | { 23 | frontpage: location(id: ${rootLocationId}) { 24 | content { 25 | fields(identifier: ["title", "body"]) { 26 | fieldDefIdentifier, 27 | value { 28 | ... on TextLineFieldValue { 29 | text 30 | } 31 | ... on RichTextFieldValue { 32 | html5 33 | } 34 | ... on ImageFieldValue { 35 | uri 36 | } 37 | } 38 | } 39 | } 40 | } 41 | activities: locationChildren(id: ${rootLocationId}) { 42 | content { 43 | id 44 | name 45 | } 46 | } 47 | } 48 | 49 | `; 50 | 51 | let variables = {}; 52 | 53 | return await client.query(query, variables); 54 | } 55 | 56 | render() { 57 | 58 | let fields = simplifyFields(this.props.data.frontpage.content.fields); 59 | 60 | return ( 61 | 62 | 63 | {fields.title} 64 | 65 |
66 |

{fields.title}

67 | {renderHTML(fields.body)} 68 |
69 | 72 |
73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const { createServer } = require("http"); 2 | const { parse } = require("url"); 3 | const next = require("next"); 4 | const pathMatch = require("path-match"); 5 | 6 | const port = parseInt(process.env.PORT, 10) || 3000; 7 | const dev = process.env.NODE_ENV !== "production"; 8 | const app = next({ dev }); 9 | const handle = app.getRequestHandler(); 10 | const route = pathMatch(); 11 | const match = route("/activity/:id"); 12 | 13 | app.prepare().then(() => { 14 | createServer((req, res) => { 15 | const { pathname, query } = parse(req.url, true); 16 | const params = match(pathname); 17 | if (params === false) { 18 | handle(req, res); 19 | return; 20 | } 21 | app.render(req, res, "/activity", Object.assign(params, query)); 22 | }).listen(port, err => { 23 | if (err) throw err; 24 | console.log(`> Ready on http://localhost:${port}`); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /static/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janit/decoupled-cms-nextjs-graphql/241697b09355adfce3d5844a9ff79a755b9d63a0/static/images/favicon.ico -------------------------------------------------------------------------------- /static/images/react-rauma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janit/decoupled-cms-nextjs-graphql/241697b09355adfce3d5844a9ff79a755b9d63a0/static/images/react-rauma.png --------------------------------------------------------------------------------