Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project --quickstart
Notion is a popular note-taking tool loved by many. Using blocks of content and a variety of components within its pages, Notion makes writing and editing content a breeze by supporting many types of formats (images, links, tables, and more).
In this tutorial, you’ll learn to make a simplified clone of Notion using Strapi and Next.js. Strapi is the leading open-source CMS and will act as our content hub for this project. Not only is Strapi fast, but it is also very developer-friendly. You can consume the Strapi API from any client using REST or GraphQL.
Here, in part 2 of this series, you will create a Next.js client and interact with your Strapi API through GraphQL queries and mutations.
If you do not have a Strapi backend, do not hesitate to check out part 1, which provides a step-by-step guide on creating a Strapi project with GraphQL.
Now, read on to learn how to build your Notion clone.
To start with, create a Next.js server by entering this command in your terminal:
1
2
3
4
5
npx create-next-app
# or
yarn create next-app
Tip: Next.js has tons of example projects. There are only seven GraphQL examples. You can borrow one if you like; otherwise, just create a server with the command above.
Once your Next.js server is created, you need to install graphql-hooks
. This lightweight package offers a series of GraphQL hooks, allowing you to easily interact with your backend. There are, however, many GraphQL packages for React out there, and you are free to choose the one you prefer.
Note: if you are interested in reading more about this package, check out the documentation.
1
2
3
4
5
npm install graphql-hooks
# or
yarn add graphql-hooks
Tip: also looking to cache your data? GraphQL hook has a graphql-hooks-memcache
plug-in if you would like to implement in-memory caching.
In your Next.js project, create a lib
folder and add a graphql-client.js
file. Inside, you will have a function to create a GraphQL client and initialize it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { useMemo } from "react";
import { GraphQLClient } from "graphql-hooks";
import memCache from "graphql-hooks-memcache";
let graphQLClient;
//Create a GraphQL client by connecting to your API
function createClient(initialState) {
return new GraphQLClient({
ssrMode: typeof window === "undefined",
url: "http://localhost:1337/graphql", // Server URL (must be absolute)
cache: memCache({ initialState }),
});
}
// Initialize your GraphQL client or return the existing one
export function initializeGraphQL(initialState = null) {
const _graphQLClient = graphQLClient ?? createClient(initialState);
// For SSG and SSR always create a new GraphQL Client
if (typeof window === "undefined") return _graphQLClient;
// Create the GraphQL Client once in the client
if (!graphQLClient) graphQLClient = _graphQLClient;
return _graphQLClient;
}
export function useGraphQLClient(initialState) {
const store = useMemo(() => initializeGraphQL(initialState), [initialState]);
return store;
}
Then in your _app.js
, import your new useGraphQLClient
function. Initialize your GraphQL client and pass it in a context so that it is available in your children components.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import "../styles/global.css";
import { ClientContext } from "graphql-hooks";
import { useGraphQLClient } from "../lib/graphql-client";
export default function App({ Component, pageProps }) {
const graphQLClient = useGraphQLClient();
return (
<ClientContext.Provider value={graphQLClient}>
<Component {...pageProps} />
</ClientContext.Provider>
);
}
Remember the GraphQL you used in your playground back in part 1? It is now time for you to set up the queries and mutations you will be using. In your lib
folder, create a graphql-query-mutation.js
file. Inside that file, add your queries and mutations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
export const ALL_PAGES_QUERY = `
query getAllPages {
pages {
id,
title
}
}
`;
export const PAGE_QUERY = `
query getPage($id: ID!) {
page(id: $id) {
id,
title,
content_blocks {
id,
content,
}
}
}
`;
export const CREATE_PAGE_MUTATION = `
mutation createPage($title: String!) {
createPage(input: { data: { title: $title}}) {
page {
id,
title
}
}
}`;
export const UPDATE_PAGE_MUTATION = `
mutation updatePage($id: ID!, $title: String!) {
updatePage(input: {
where: { id: $id }
data: {
title: $title
}
}) {
page {
id,
title,
content_blocks {
id,
content,
order
}
}
}
}
`;
export const DELETE_PAGE_MUTATION = `
mutation deletePage($id: ID!) {
deletePage(input: {
where: { id: $id }
}) {
page {
id,
title
}
}
}`;
export const CREATE_BLOCKS_MUTATION = `
mutation createContentBlock($content: String!, $pageId: ID!) {
createContentBlock(input: {
data: {
content: $content,
page: $pageId
}
})
{
contentBlock {
id,
page {
id
},
content,
}
}
}
`;
export const UPDATE_BLOCKS_MUTATION = `
mutation updateContentBlock($id: ID!, $content: String!) {
updateContentBlock(input: {
where: { id: $id},
data: {
content: $content
}
})
{
contentBlock {
id,
content,
}
}
}
`;
export const DELETE_BLOCK_MUTATION = `
mutation deleteContentBlock($id: ID!) {
deleteContentBlock(input: {
where: { id: $id }
}) {
contentBlock {
id,
content
}
}
}`;
Now that your frontend is connected to your GraphQL backend and you have queries and mutations in place, it’s time to get started on UI creation.
This tutorial will use MUI to build the frontend. It’s a great library with tons of prebuilt components. You do not have to use it, but if you are interested, run these commands to install it:
1
2
3
4
5
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
# or
yarn add @mui/material @emotion/react @emotion/styled @mui/icons-material
Note: to have access to the MUI icons, you also need to install the icons separately.
Also, head to your _app.js
and add a CssBaseline
around your app. Here is the end result:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import "../styles/global.css";
import { ClientContext } from "graphql-hooks";
import { useGraphQLClient } from "../lib/graphql-client";
import CssBaseline from "@mui/material/CssBaseline";
import Container from "@mui/material/Container";
export default function App({ Component, pageProps }) {
const graphQLClient = useGraphQLClient();
return (
<CssBaseline>
<Container maxWidth="lg">
<ClientContext.Provider value={graphQLClient}>
<Component {...pageProps} />
</ClientContext.Provider>
</Container>
</CssBaseline>
);
}
Going back to your project, start by listing all your pages in the homepage (i.e., at the URL /
). In your index.js
, use the useQuery
from graphql-hooks
to retrieve your pages from your Strapi backend.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import { useQuery, useMutation } from "graphql-hooks";
import { useState } from "react";
import Link from "next/link";
import Typography from "@mui/material/Typography";
import Grid from "@mui/material/Grid";
import {
ALL_PAGES_QUERY,
} from "../lib/graphql-query-mutation";
export default function Home() {
//use your ALL_PAGES_QUERY query to retrieve your pages
const { data } = useQuery(ALL_PAGES_QUERY);
if (!data) return <div>Loading...</div>;
const { pages } = data;
return (
<section>
<Grid container direction="column" spacing={2}>
<Grid item>
<Typography variant="h3">Welcome</Typography>
</Grid>
<Grid item>
<Typography variant="body1">
This project uses Strapi + GraphQL + Next.js. It illustrates how to
use these technologies to create a Notion-like project (with block
content).
</Typography>
</Grid>
<Grid item>
<Typography variant="h6">All pages</Typography>
</Grid>
<Grid container direction="column" spacing={1}>
{pages.map((page, index) => (
<Grid item key={page.id}>
<div>
<span>{index + 1}. </span>
<Link href={`/${page.id}`}>
<a>{page.title}</a>
</Link>
</div>
</Grid>
))}
</Grid>
</Grid>
</section>
)
}
export async function getStaticProps() {
return {
props: {},
};
}
You will notice a Link
component around your title. This built-in piece from Next.js allows the user to navigate through your app. In this particular case, the user will go to a specific page with the URL /[page-id]
.
You might also notice the getStaticProps
function at the bottom. This is a particularity of Next.js and simply specifies that the page will be static. In other words, it will be pre-rendered at build time. For more information, check out Next.js data fetching.
To implement the single page, create a [id].js
file in your pages
folder.
Inside, use your PAGE_QUERY
to retrieve the data for a single page. You will notice that you also need getServerSideProps
, as the page is rendered server-side.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import { useQuery } from "graphql-hooks";
import Typography from "@mui/material/Typography";
import Grid from "@mui/material/Grid";
import Breadcrumbs from "../components/breadcrumbs";
import {
PAGE_QUERY,
} from "../lib/graphql-query-mutation";
export default function SinglePage({ id }) {
const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
if (!data) return <div>Loading</div>;
const { page } = data;
const { title } = page;
return (
<Grid container>
<Breadcrumbs id={id} title={title} />
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Grid item>
<Typography variant="h3">{title}</Typography>
</Grid>
</Grid>
</Grid>
);
}
export async function getServerSideProps(context) {
const { id } = context.params;
return {
props: { id },
};
}
You can also create a Breadcrumbs
component to add more navigation to your application:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import Breadcrumbs from "@mui/material/Breadcrumbs";
import Link from "@mui/material/Link";
export default function CustomBreadcrumbs({ id, title }) {
return (
<Breadcrumbs aria-label="breadcrumb" style={{ padding: "20px 0px" }}>
<Link color="inherit" href="/">
All Pages
</Link>
<Link color="textPrimary" href={`/${id}`} aria-current="page">
{title}
</Link>
</Breadcrumbs>
);
}
Now that you have retrieved data for all your pages, along with a single page, try creating a new one. Back to your index.js
, import your CREATE_PAGE_MUTATION
mutation and retrieve the createPage
function. Once you are ready to create a page, call the function to create a page and call refetch to get the latest version of your data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import { useQuery, useMutation } from "graphql-hooks";
import { useState } from "react";
import Link from "next/link";
import Typography from "@mui/material/Typography";
import Grid from "@mui/material/Grid";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import {
ALL_PAGES_QUERY,
CREATE_PAGE_MUTATION,
} from "../lib/graphql-query-mutation";
export default function Home() {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
// refetch allows you to call the query again and refresh data
const { data, refetch } = useQuery(ALL_PAGES_QUERY);
//createPage function allows you to call the mutation function
// which will your mutate function and create a page in your Strapi datastore
const [createPage] = useMutation(CREATE_PAGE_MUTATION);
const handleClickOpen = () => {
setOpen(true);
};
const handleClose = () => {
setTitle("");
setOpen(false);
};
const addPage = async () => {
// call the create page mutate function
await createPage({ variables: { title } });
// call refetch to retrieve the new list of pages
refetch();
// close the "create a new page" dialog
handleClose();
};
if (!data) return <div>Loading...</div>;
const { pages } = data;
return (
<section>
<Grid container direction="column" spacing={2}>
<Grid item>
<Typography variant="h3">Welcome</Typography>
</Grid>
<Grid item>
<Typography variant="body1">
This project uses Strapi + GraphQL + Next.js. It illustrates how to
use these technologies to create a Notion-like project (with block
content).
</Typography>
</Grid>
<Grid item>
<Typography variant="h6">All pages</Typography>
</Grid>
<Grid container direction="column" spacing={1}>
{pages.map((page, index) => (
<Grid item key={page.id}>
<div>
<span>{index + 1}. </span>
<Link href={`/${page.id}`}>
<a>{page.title}</a>
</Link>
</div>
</Grid>
))}
</Grid>
<Grid item>
<Button color="primary" onClick={handleClickOpen}>
Add page
</Button>
</Grid>
</Grid>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="form-dialog-title"
>
<DialogTitle id="form-dialog-title">Add New Page</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
id="name"
label="Title"
type="text"
fullWidth
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} color="primary">
Cancel
</Button>
<Button onClick={addPage} color="primary">
Create
</Button>
</DialogActions>
</Dialog>
</section>
);
}
export async function getStaticProps() {
return {
props: {},
};
}
Now that you have managed to create a new page, you can try to update it or even delete it.
In your [id].js
file, import your DELETE_PAGE_MUTATION
and UPDATE_PAGE_MUTATION
mutations. Similar to the previous steps, retrieve your updatePage
and deletePage
function from the useMutation
hook.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
import { useState } from "react";
import { useQuery, useMutation } from "graphql-hooks";
import { useRouter } from "next/router";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Breadcrumbs from "../components/breadcrumbs";
import Content from "../components/content";
import {
PAGE_QUERY,
DELETE_PAGE_MUTATION,
UPDATE_PAGE_MUTATION,
} from "../lib/graphql-query-mutation";
export default function SinglePage({ id }) {
// for your textfield
const [newTitle, setNewTitle] = useState("");
// edit mode on and off
const [editTitle, setEditTitle] = useState(false);
const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
const [updatePage] = useMutation(UPDATE_PAGE_MUTATION);
const [deletePage] = useMutation(DELETE_PAGE_MUTATION);
const router = useRouter();
if (!data) return <div>Loading</div>;
const { page } = data;
const { title } = page;
const updateTitle = async () => {
// Update page with new title
await updatePage({ variables: { id, title: newTitle } });
// set edit mode to false
setEditTitle(false);
setNewTitle("");
// Get the latest data
refetch();
};
const remove = async () => {
await deletePage({ variables: { id } });
//redirect to homepage after deletion.
router.push(`/`);
};
return (
<Grid container>
<Breadcrumbs id={id} title={title} />
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
>
<Grid item>
{editTitle ? (
<Grid item>
<TextField
label="New Title"
variant="outlined"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
/>
<Button
variant="outlined"
color="primary"
onClick={updateTitle}
style={{ marginLeft: 20 }}
>
Save
</Button>
</Grid>
) : (
<Grid item>
<Typography variant="h3">{title}</Typography>
</Grid>
)}
</Grid>
<Grid item>
<Button
variant="outlined"
color="primary"
onClick={() => setEditTitle(true)}
style={{ marginRight: 20 }}
>
Edit Title
</Button>
<Button variant="outlined" color="secondary" onClick={remove}>
Delete
</Button>
</Grid>
</Grid>
</Grid>
);
}
{...}
Now that your page functionality has been implemented, you can move on to the content blocks.
Start by creating a component
folder where all your React components will live. In this folder, create new content-block.js
and content.js
files. The ContentBlock
component will be your block itself, whereas Content
will be a list of those blocks.
Note: this tutorial uses dangerouslySetInnerHTML
to render the raw HTML of the content block. While this is a tutorial and not meant for a production environment, there are loads of npm packages to safely render HTML in React—feel free to try them out.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import Grid from "@mui/material/Grid";
import ContentBlock from "./content-block";
// content.js
export default function Content({ pageId, refetchPage, content_blocks = [] }) {
return (
<Grid container direction="column">
<Grid item>
{content_blocks.map((block) => (
<Grid item key={block.id}>
<ContentBlock block={block} refetchPage={refetchPage} />
</Grid>
))}
</Grid>
</Grid>
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import Grid from "@mui/material/Grid";
export default function ContentBlock({ refetchPage, block }) {
return (
<Grid container direction="row" alignItems="center">
<Grid item>
<div
dangerouslySetInnerHTML={{ __html: block ? block.content : "" }}
></div>
</Grid>
</Grid>
);
}
Then in your [id].js
file, retrieve your content blocks from your data.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import Content from "../components/content";
export default function SinglePage({ id }) {
{...}
const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
const { page } = data;
const { content_blocks, title } = page;
return (
<Grid container>
{...}
<Content
pageId={id}
refetchPage={refetch}
content_blocks={content_blocks}
/>
</Grid>
);
}
As mentioned previously, one of Notion’s best features is its ability to create content blocks. To implement this, this tutorial will use Draft.js as the HTML editor. More specifically, it will use the react-draft-wysiwyg
package, as this is made for React and built on Draft.js. Go ahead and install it:
1
2
3
4
5
npm install react-draft-wysiwyg draft-js draftjs-to-html html-to-draftjs
# or
yarn add react-draft-wysiwyg draft-js draftjs-to-html html-to-draftjs
Then create an editor.js
file in your component folder.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import React, { useState } from "react";
import { EditorState, convertToRaw, ContentState } from "draft-js";
import { Editor } from "react-draft-wysiwyg";
import Button from "@mui/material/Button";
import draftToHtml from "draftjs-to-html";
import htmlToDraft from "html-to-draftjs";
import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
export default function CustomEditor({ saveBlock, content }) {
var initalStateEditor;
// If you want to populate with existing content, turn the html string
// into Draft blocks and create an editor state
// else create an empty editor state
if (content) {
const contentBlock = htmlToDraft(content);
const contentState = ContentState.createFromBlockArray(
contentBlock.contentBlocks
);
initalStateEditor = EditorState.createWithContent(contentState);
} else {
initalStateEditor = EditorState.createEmpty();
}
const [editorState, setEditorState] = useState(initalStateEditor);
const onEditorStateChange = (editorState) => {
setEditorState(editorState);
};
// convert draft blocks back to a raw HTML string
const onEditorSave = () => {
const newContent = draftToHtml(
convertToRaw(editorState.getCurrentContent())
);
saveBlock(newContent);
};
return (
<div>
<div>
<Editor
editorState={editorState}
wrapperClassName="demo-wrapper"
editorClassName="demo-editor"
onEditorStateChange={onEditorStateChange}
/>
</div>
<Button variant="outlined" color="secondary" onClick={onEditorSave}>
Save
</Button>
</div>
);
}
Finally, in your content.js
file, add a button called “Add content block”. When the user clicks on it, the editor will open, and they will be able to input content and save it to your Strapi backend.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import { useState } from "react";
import { useMutation } from "graphql-hooks";
import dynamic from "next/dynamic";
import Grid from "@mui/material/Grid";
import Button from "@mui/material/Button";
import ContentBlock from "./content-block";
const Editor = dynamic(() => import("./editor"));
import { CREATE_BLOCKS_MUTATION } from "../lib/graphql-query-mutation";
export default function Content({ pageId, refetchPage, content_blocks = [] }) {
// toggle add block mode or not
const [openEditor, setOpenEditor] = useState(false);
const [createContentBlock] = useMutation(CREATE_BLOCKS_MUTATION);
const addBlock = async (content) => {
// create a new block with your string content and specify to
// which page the block belongs to.
await createContentBlock({ variables: { content, pageId } });
setOpenEditor(false);
// refetch the page to get the latest data
refetchPage();
};
return (
<Grid container direction="column">
<Grid item>
{content_blocks.map((block) => (
<Grid item key={block.id}>
<ContentBlock block={block} refetchPage={refetchPage} />
</Grid>
))}
</Grid>
<Grid item>
{openEditor ? (
<Editor saveBlock={addBlock} />
) : (
<Button color="primary" onClick={() => setOpenEditor(true)}>
Add content block
</Button>
)}
</Grid>
</Grid>
);
}
There are a lot of ways to go about editing or deleting a content block. In this particular tutorial, you will be implementing the edit and delete functionality through a menu. By adding an IconButton
, the user can see the three dots. And by clicking that button, they are presented with an action menu with Edit
and Delete
as options. In your components/content-blocks.js
, add your delete and edit functionalities.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
import { useMutation } from "graphql-hooks";
import { useState } from "react";
import dynamic from "next/dynamic";
import Grid from "@mui/material/Grid";
import IconButton from "@mui/material/IconButton";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
const Editor = dynamic(() => import("./editor"));
import {
DELETE_BLOCK_MUTATION,
UPDATE_BLOCKS_MUTATION,
} from "../lib/graphql-query-mutation";
export default function ContentBlock({ refetchPage, block }) {
// edit mode. If true, the editor appears. If not, the content is
// rendered.
const [openEditor, setOpenEditor] = useState(false);
const [deleteContentBlock] = useMutation(DELETE_BLOCK_MUTATION);
const [updateContentBlock] = useMutation(UPDATE_BLOCKS_MUTATION);
// for the edit/delete menu
const [anchorEl, setAnchorEl] = useState(null);
const { id } = block;
//when the three dot icon is clicked, set the anchor so the menu knows where to open
const handleClick = (event) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleDelete = async () => {
await deleteContentBlock({ variables: { id } });
refetchPage();
handleClose();
};
const handleEdit = () => {
handleClose();
setOpenEditor(true);
};
const saveBlock = async (content) => {
await updateContentBlock({ variables: { id, content } });
setOpenEditor(false);
refetchPage();
};
// if edit more is true
if (openEditor)
return (
<div>
<Editor content={block.content} saveBlock={saveBlock} />
</div>
);
//if edit mode is false
return (
<Grid container direction="row" alignItems="center">
<Grid item>
<IconButton
aria-label="options"
aria-controls="simple-menu"
aria-haspopup="true"
onClick={handleClick}
size="small"
>
<MoreVertIcon />
</IconButton>
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={handleEdit}>Edit</MenuItem>
<MenuItem onClick={handleDelete}>Delete</MenuItem>
</Menu>
</Grid>
<Grid item>
<div
dangerouslySetInnerHTML={{ __html: block ? block.content : "" }}
></div>
</Grid>
</Grid>
);
}
To start your app, you should have two terminal tabs—one for your Next.js server and the other for your Strapi backend. In the first tab, navigate to your frontend project and run yarn dev
. In the second tab, head to your Strapi backend project and run yarn start
. Your app will then be available to view at http://localhost:3000/, where you should see the final product:
In part 1 of this tutorial, you learned how to set up a Strapi backend with GraphQL.You also became more familiar with this technology by trying out the GraphQL playground with your new queries and mutations.
Then in part 2, you discovered how to set up a Next.js client with GraphQL. You set up static and dynamic routes for your pages and added the ability not only to create them but also to edit and delete them. Finally, you learned how to create content blocks and save them to your pages like you would in Notion.
For more information, the complete codebase can be found on github.
Marie Starck is a full-stack software developer. She loves frontend technologies such as React. In her free time, she also writes tech tutorials. if she could, she would get paid in chocolate bars.
Get all the latest Strapi updates, news and events.