├── .yarnrc ├── cms.png ├── .gitignore ├── dev.sh ├── next.config.js ├── components └── post.js ├── .editorconfig ├── pages ├── index.js └── post │ └── [pid].js ├── package.json ├── util ├── find-site.js └── get-token.js ├── lib └── spo.js └── README.md /.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix "" 2 | -------------------------------------------------------------------------------- /cms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlliV/spo-headless-cms/HEAD/cms.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .next 2 | node_modules 3 | out/ 4 | tenant.json 5 | yarn-error.log 6 | -------------------------------------------------------------------------------- /dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/env bash 2 | 3 | temp=`node util/get-token.js|jq .access_token` 4 | temp="${temp%\"}" 5 | token="${temp#\"}" 6 | 7 | ACCESS_TOKEN="$token" yarn dev 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | exportTrailingSlash: true, 3 | exportPathMap: function() { 4 | return { 5 | '/': { page: '/' } 6 | }; 7 | }, 8 | env: { 9 | SITE_ID: process.env.SITE_ID, 10 | ACCESS_TOKEN: process.env.ACCESS_TOKEN 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /components/post.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | function Post({ page }) { 4 | const { 5 | id: pageId, 6 | title: pageTitle 7 | } = page; 8 | const author = page.createdBy.user.displayName; 9 | const date = `${new Date(page.lastModifiedDateTime)}`; 10 | 11 | return ( 12 |
13 |
14 |

{pageTitle}

15 |

{author}

16 | {date} 17 |
18 |
19 | ); 20 | } 21 | 22 | export default Post; 23 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | tab_width = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{*.json}] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.md] 17 | indent_style = space 18 | indent_size = 4 19 | trim_trailing_whitespace = false 20 | 21 | # Ideal settings - some plugins might support these. 22 | [*.js] 23 | quote_type = single 24 | 25 | [{*.js,*.jsx,*.ts}] 26 | curly_bracket_next_line = false 27 | spaces_around_operators = true 28 | spaces_around_brackets = outside 29 | # close enough to 1TB 30 | indent_brace_style = K&R 31 | -------------------------------------------------------------------------------- /pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import Post from '../components/post'; 3 | import { getPagesList } from '../lib/spo'; 4 | 5 | const Index = ({ pages }) => ( 6 | <> 7 | 8 | Next.js + SharePoint Online 9 | 14 | 15 | {pages.length > 0 16 | ? pages.map(p => ( 17 | 21 | )) 22 | : null} 23 | 24 | ) 25 | 26 | export async function getStaticProps() { 27 | return { props: { pages: await getPagesList() } }; 28 | } 29 | 30 | export default Index; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spo-headless-cms", 3 | "private": true, 4 | "description": "Headless CMS with Next.js and SharePoint Online", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "rm -rf out && NEXT_TELEMETRY_DISABLED=1 next build && NEXT_TELEMETRY_DISABLED=1 next export", 8 | "dev": "NEXT_TELEMETRY_DISABLED=1 next dev" 9 | }, 10 | "keywords": [ 11 | "SPO", 12 | "SharePoint", 13 | "headless CMS", 14 | "CMS" 15 | ], 16 | "author": "Olli Vanhoja ", 17 | "license": "ISC", 18 | "dependencies": { 19 | "html-to-react": "1.4.2", 20 | "isomorphic-unfetch": "3.0.0", 21 | "next": "9.3.5", 22 | "react": "16.13.1", 23 | "react-dom": "16.13.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /util/find-site.js: -------------------------------------------------------------------------------- 1 | const fetch = require('isomorphic-unfetch'); 2 | const getToken = require('./get-token'); 3 | 4 | async function findSites(searchTerm) { 5 | const { token_type, access_token } = await getToken(); 6 | const res = await fetch(`https://graph.microsoft.com/v1.0/sites?search=${encodeURIComponent(searchTerm)}`, { 7 | headers: { 8 | Authorization: `${token_type} ${access_token}` 9 | } 10 | }); 11 | const body = await res.json(); 12 | 13 | if (!res.ok) { 14 | const { error } = body; 15 | const err = new Error('Search failed'); 16 | 17 | err.originalError = error; 18 | 19 | throw err; 20 | } 21 | 22 | 23 | return body; 24 | } 25 | module.exports = findSites; 26 | 27 | if (require.main === module) { 28 | const searchTerm = process.argv.slice(2).join(' '); 29 | 30 | findSites(searchTerm) 31 | .then((body) => console.log(JSON.stringify(body, null, 2))) 32 | .catch(console.error); 33 | } 34 | -------------------------------------------------------------------------------- /util/get-token.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const fetch = require('isomorphic-unfetch'); 3 | 4 | const { 5 | tenantId, 6 | clientId, 7 | clientSecret 8 | } = readConfig(); 9 | 10 | function readConfig() { 11 | const data = fs.readFileSync('./tenant.json'); 12 | 13 | if (data) { 14 | return JSON.parse(data); 15 | } else { 16 | throw new Error(`JSON parsing error: ${parametersFile}`); 17 | } 18 | } 19 | 20 | async function getToken(raw = false) { 21 | const res = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { 22 | method: 'POST', 23 | headers: { 24 | 'Content-Type': 'application/x-www-form-urlencoded' 25 | }, 26 | body: `client_id=${clientId}&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default&client_secret=${clientSecret}&grant_type=client_credentials` 27 | }); 28 | 29 | if (!res.ok) { 30 | console.error('Auth failed'); 31 | process.exit(1); 32 | } 33 | 34 | return raw ? res.text() : res.json(); 35 | } 36 | module.exports = getToken; 37 | 38 | // Only print a token if the file was called directly as a command. 39 | if (require.main === module) { 40 | getToken(!process.stdout.isTTY).then(console.log).catch(console.error); 41 | } 42 | -------------------------------------------------------------------------------- /pages/post/[pid].js: -------------------------------------------------------------------------------- 1 | import HtmlToReactParser from 'html-to-react'; 2 | import ErrorPage from 'next/error'; 3 | import { getPagesList, getPage } from '../../lib/spo'; 4 | 5 | const { Parser } = HtmlToReactParser; 6 | var htmlToReactParser = new Parser(); 7 | 8 | const Page = ({ page }) => { 9 | if (!page) { 10 | return ; 11 | } 12 | 13 | const author = page.createdBy.user.displayName; 14 | const date = `${new Date(page.lastModifiedDateTime)}`; 15 | 16 | return ( 17 | <> 18 |

{page.title}

19 | {author} {date} 20 | {page.webParts.length > 0 21 | ? page.webParts 22 | .filter((part) => part.type === 'rte') 23 | .map((part, i) => (
{htmlToReactParser.parse(part.data.innerHTML)}
)) 24 | : null} 25 | 26 | ); 27 | }; 28 | 29 | export async function getStaticPaths() { 30 | console.log('Getting page index'); 31 | 32 | const pages = await getPagesList(); 33 | 34 | return { 35 | paths: pages.map((p) => `/post/${p.id}`), 36 | fallback: false 37 | }; 38 | } 39 | 40 | export async function getStaticProps({ params }) { 41 | console.log(`Getting page: ${params.pid}`); 42 | 43 | const page = await getPage(params.pid); 44 | 45 | if (!page) { 46 | throw new Error('Page not found'); 47 | } 48 | 49 | return { props: { page } }; 50 | } 51 | 52 | export default Page; 53 | -------------------------------------------------------------------------------- /lib/spo.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-unfetch'; 2 | 3 | const BASE_URL = 'https://graph.microsoft.com/beta'; 4 | const SITE_ID = process.env.SITE_ID; 5 | 6 | if (typeof SITE_ID !== 'string' || SITE_ID.length === 0) { 7 | throw new Error('SITE_ID is missing or invalid'); 8 | } 9 | 10 | export async function getPagesList() { 11 | const filter = `pageLayout eq 'Article' and publishingState/level eq 'published'`; 12 | const select = 'id,title,createdBy,lastModifiedDateTime,pageLayout,publishingState' 13 | const res = await fetch(`${BASE_URL}/sites/${process.env.SITE_ID}/pages?$filter=${filter}&$select=${select}`, { 14 | headers: { 15 | Authorization: `Bearer ${process.env.ACCESS_TOKEN}` 16 | } 17 | }); 18 | 19 | if (res.status !== 200) { 20 | const { error } = await res.json(); 21 | 22 | const err = new Error(`Failed to list pages: ${res.statusText}`); 23 | err.originalError = error; 24 | 25 | throw err; 26 | } 27 | 28 | const { value: pages } = await res.json(); 29 | 30 | // console.log('Pages:', JSON.stringify(pages, null, 2)) 31 | 32 | return pages; 33 | } 34 | 35 | export async function getPage(pageId) { 36 | const res = await fetch(`${BASE_URL}/sites/${process.env.SITE_ID}/pages/${pageId}`, { 37 | headers: { 38 | Authorization: `Bearer ${process.env.ACCESS_TOKEN}` 39 | } 40 | }); 41 | 42 | if (res.status !== 200) { 43 | throw new Error(`Failed to get a page: ${res.statusText}`); 44 | } 45 | 46 | return res.json(); 47 | } 48 | 49 | // We can also get just the webParts but it remains a mystery how to expand the 50 | // instances: 51 | // /pages/${pageId}/webParts 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SharePoint Online as a Headless CMS for Next.js 2 | =============================================== 3 | 4 | It had to be done by someone. 5 | 6 | ![SPO CMS](/cms.png) 7 | 8 | Features 9 | -------- 10 | 11 | - A front page with a list of all `Article`s from a SharePoint Online site 12 | - An SSG page that generates static pages for all articles 13 | - WebParts supported: 14 | - `rte` 15 | 16 | Building 17 | -------- 18 | 19 | Create a file called `tenant.json` with the following properties 20 | (and never deploy this to production): 21 | 22 | ```js 23 | { 24 | "tenantId": "YOUR_TENANT_ID", 25 | "clientID": "YOUR_CLIENT_ID", 26 | "clientSecret": "YOUR_CLIENT_SECRET" 27 | } 28 | ``` 29 | 30 | Obviously you'll need to create an application for this in Azure AD. It's 31 | pretty simple, just create a bare minimum app and create an API token. 32 | Then make `Sites.Read.All` the only API permission for the app. 33 | 34 | First you need to figure out the ID of your site. It's a bit tricky to 35 | find, but there is a tool for that 36 | 37 | ``` 38 | node util/find-site.js My site 39 | ``` 40 | 41 | Copy the `id` somewhere, we are going to need it later as a value for 42 | the `SITE_ID` environment variable. 43 | 44 | Next up, the actual build: 45 | 46 | **The manual way** 47 | 48 | Run `get-id.js` to get a temporary authentication token: 49 | 50 | ``` 51 | $ node util/get-id.js 52 | ``` 53 | 54 | Copy the `access_token` printed as we'll need it next. 55 | 56 | Now you can run the build: 57 | 58 | ``` 59 | $ ACCESS_TOKEN='YOU_KNOW_THIS' SITE_ID='YOUR_SITE_ID' yarn build 60 | ``` 61 | 62 | **The more automated way** 63 | 64 | Just run: 65 | 66 | ``` 67 | ./build.sh 68 | ``` 69 | 70 | Finally the result will be in the newly created `out` directory that can be 71 | deployed to production. 72 | 73 | If you have `jq` installed and you are using Linux, then things will be a bit 74 | easier for you. 75 | 76 | Summary 77 | ------- 78 | 79 | **Find a site ID:** 80 | 81 | ``` 82 | node ./util/find-site.js My site 83 | ``` 84 | 85 | **To start dev mode:** 86 | 87 | ``` 88 | SITE_ID='YOUR_SITE_ID' ./dev.sh 89 | ``` 90 | 91 | **To run the build:** 92 | 93 | ``` 94 | SITE_ID='YOUR_SITE_ID' ./build.sh 95 | ``` 96 | --------------------------------------------------------------------------------