├── .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 |
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 | 
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 |
--------------------------------------------------------------------------------