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 npx create-next-app
2
3 # or
4
5 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 npm install graphql-hooks
2
3 # or
4
5 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 import { useMemo } from "react";
2
3 import { GraphQLClient } from "graphql-hooks";
4
5 import memCache from "graphql-hooks-memcache";
6
7 let graphQLClient;
8
9 //Create a GraphQL client by connecting to your API
10
11 function createClient(initialState) {
12
13 return new GraphQLClient({
14
15 ssrMode: typeof window === "undefined",
16
17 url: "http://localhost:1337/graphql", // Server URL (must be absolute)
18
19 cache: memCache({ initialState }),
20
21 });
22
23 }
24
25 // Initialize your GraphQL client or return the existing one
26
27 export function initializeGraphQL(initialState = null) {
28
29 const _graphQLClient = graphQLClient ?? createClient(initialState);
30
31 // For SSG and SSR always create a new GraphQL Client
32
33 if (typeof window === "undefined") return _graphQLClient;
34
35 // Create the GraphQL Client once in the client
36
37 if (!graphQLClient) graphQLClient = _graphQLClient;
38
39 return _graphQLClient;
40
41 }
42
43 export function useGraphQLClient(initialState) {
44
45 const store = useMemo(() => initializeGraphQL(initialState), [initialState]);
46
47 return store;
48
49 }
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 import "../styles/global.css";
2
3 import { ClientContext } from "graphql-hooks";
4
5 import { useGraphQLClient } from "../lib/graphql-client";
6
7 export default function App({ Component, pageProps }) {
8
9 const graphQLClient = useGraphQLClient();
10
11 return (
12
13 <ClientContext.Provider value={graphQLClient}>
14
15 <Component {...pageProps} />
16
17 </ClientContext.Provider>
18
19 );
20
21 }
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 export const ALL_PAGES_QUERY = `
2
3 query getAllPages {
4
5 pages {
6
7 id,
8
9 title
10
11 }
12
13 }
14
15 `;
16
17 export const PAGE_QUERY = `
18
19 query getPage($id: ID!) {
20
21 page(id: $id) {
22
23 id,
24
25 title,
26
27 content_blocks {
28
29 id,
30
31 content,
32
33 }
34
35 }
36
37 }
38
39 `;
40
41 export const CREATE_PAGE_MUTATION = `
42
43 mutation createPage($title: String!) {
44
45 createPage(input: { data: { title: $title}}) {
46
47 page {
48
49 id,
50
51 title
52
53 }
54
55 }
56
57 }`;
58
59 export const UPDATE_PAGE_MUTATION = `
60
61 mutation updatePage($id: ID!, $title: String!) {
62
63 updatePage(input: {
64
65 where: { id: $id }
66
67 data: {
68
69 title: $title
70
71 }
72
73 }) {
74
75 page {
76
77 id,
78
79 title,
80
81 content_blocks {
82
83 id,
84
85 content,
86
87 order
88
89 }
90
91 }
92
93 }
94
95 }
96
97 `;
98
99 export const DELETE_PAGE_MUTATION = `
100
101 mutation deletePage($id: ID!) {
102
103 deletePage(input: {
104
105 where: { id: $id }
106
107 }) {
108
109 page {
110
111 id,
112
113 title
114
115 }
116
117 }
118
119 }`;
120
121 export const CREATE_BLOCKS_MUTATION = `
122
123 mutation createContentBlock($content: String!, $pageId: ID!) {
124
125 createContentBlock(input: {
126
127 data: {
128
129 content: $content,
130
131 page: $pageId
132
133 }
134
135 })
136
137 {
138
139 contentBlock {
140
141 id,
142
143 page {
144
145 id
146
147 },
148
149 content,
150
151 }
152
153 }
154
155 }
156
157 `;
158
159 export const UPDATE_BLOCKS_MUTATION = `
160
161 mutation updateContentBlock($id: ID!, $content: String!) {
162
163 updateContentBlock(input: {
164
165 where: { id: $id},
166
167 data: {
168
169 content: $content
170
171 }
172
173 })
174
175 {
176
177 contentBlock {
178
179 id,
180
181 content,
182
183 }
184
185 }
186
187 }
188
189 `;
190
191 export const DELETE_BLOCK_MUTATION = `
192
193 mutation deleteContentBlock($id: ID!) {
194
195 deleteContentBlock(input: {
196
197 where: { id: $id }
198
199 }) {
200
201 contentBlock {
202
203 id,
204
205 content
206
207 }
208
209 }
210
211 }`;
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:
1npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
2
3# or
4
5yarn 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 import "../styles/global.css";
2
3 import { ClientContext } from "graphql-hooks";
4
5 import { useGraphQLClient } from "../lib/graphql-client";
6
7 import CssBaseline from "@mui/material/CssBaseline";
8
9 import Container from "@mui/material/Container";
10
11 export default function App({ Component, pageProps }) {
12
13 const graphQLClient = useGraphQLClient();
14
15 return (
16
17 <CssBaseline>
18
19 <Container maxWidth="lg">
20
21 <ClientContext.Provider value={graphQLClient}>
22
23 <Component {...pageProps} />
24
25 </ClientContext.Provider>
26
27 </Container>
28
29 </CssBaseline>
30
31 );
32
33 }
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 import { useQuery, useMutation } from "graphql-hooks";
2
3 import { useState } from "react";
4
5 import Link from "next/link";
6
7 import Typography from "@mui/material/Typography";
8
9 import Grid from "@mui/material/Grid";
10
11 import {
12
13 ALL_PAGES_QUERY,
14
15 } from "../lib/graphql-query-mutation";
16
17 export default function Home() {
18
19 //use your ALL_PAGES_QUERY query to retrieve your pages
20
21 const { data } = useQuery(ALL_PAGES_QUERY);
22
23 if (!data) return <div>Loading...</div>;
24
25 const { pages } = data;
26
27 return (
28
29 <section>
30
31 <Grid container direction="column" spacing={2}>
32
33 <Grid item>
34
35 <Typography variant="h3">Welcome</Typography>
36
37 </Grid>
38
39 <Grid item>
40
41 <Typography variant="body1">
42
43 This project uses Strapi + GraphQL + Next.js. It illustrates how to
44
45 use these technologies to create a Notion-like project (with block
46
47 content).
48
49 </Typography>
50
51 </Grid>
52
53 <Grid item>
54
55 <Typography variant="h6">All pages</Typography>
56
57 </Grid>
58
59 <Grid container direction="column" spacing={1}>
60
61 {pages.map((page, index) => (
62
63 <Grid item key={page.id}>
64
65 <div>
66
67 <span>{index + 1}. </span>
68
69 <Link href={`/${page.id}`}>
70
71 <a>{page.title}</a>
72
73 </Link>
74
75 </div>
76
77 </Grid>
78
79 ))}
80
81 </Grid>
82
83 </Grid>
84
85 </section>
86
87 )
88
89 }
90
91 export async function getStaticProps() {
92
93 return {
94
95 props: {},
96
97 };
98
99 }
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 import { useQuery } from "graphql-hooks";
2
3 import Typography from "@mui/material/Typography";
4
5 import Grid from "@mui/material/Grid";
6
7 import Breadcrumbs from "../components/breadcrumbs";
8
9 import {
10
11 PAGE_QUERY,
12
13 } from "../lib/graphql-query-mutation";
14
15 export default function SinglePage({ id }) {
16
17 const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
18
19 if (!data) return <div>Loading</div>;
20
21 const { page } = data;
22
23 const { title } = page;
24
25 return (
26
27 <Grid container>
28
29 <Breadcrumbs id={id} title={title} />
30
31 <Grid
32
33 container
34
35 direction="row"
36
37 justifyContent="space-between"
38
39 alignItems="center"
40
41 >
42
43 <Grid item>
44
45 <Typography variant="h3">{title}</Typography>
46
47 </Grid>
48
49 </Grid>
50
51 </Grid>
52
53 );
54
55 }
56
57 export async function getServerSideProps(context) {
58
59 const { id } = context.params;
60
61 return {
62
63 props: { id },
64
65 };
66
67 }
You can also create a Breadcrumbs
component to add more navigation to your application:
1 import Breadcrumbs from "@mui/material/Breadcrumbs";
2
3 import Link from "@mui/material/Link";
4
5 export default function CustomBreadcrumbs({ id, title }) {
6
7 return (
8
9 <Breadcrumbs aria-label="breadcrumb" style={{ padding: "20px 0px" }}>
10
11 <Link color="inherit" href="/">
12
13 All Pages
14
15 </Link>
16
17 <Link color="textPrimary" href={`/${id}`} aria-current="page">
18
19 {title}
20
21 </Link>
22
23 </Breadcrumbs>
24
25 );
26
27 }
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 import { useQuery, useMutation } from "graphql-hooks";
2
3 import { useState } from "react";
4
5 import Link from "next/link";
6
7 import Typography from "@mui/material/Typography";
8
9 import Grid from "@mui/material/Grid";
10
11 import Button from "@mui/material/Button";
12
13 import TextField from "@mui/material/TextField";
14
15 import Dialog from "@mui/material/Dialog";
16
17 import DialogActions from "@mui/material/DialogActions";
18
19 import DialogContent from "@mui/material/DialogContent";
20
21 import DialogTitle from "@mui/material/DialogTitle";
22
23 import {
24
25 ALL_PAGES_QUERY,
26
27 CREATE_PAGE_MUTATION,
28
29 } from "../lib/graphql-query-mutation";
30
31 export default function Home() {
32
33 const [open, setOpen] = useState(false);
34
35 const [title, setTitle] = useState("");
36
37 // refetch allows you to call the query again and refresh data
38
39 const { data, refetch } = useQuery(ALL_PAGES_QUERY);
40
41 //createPage function allows you to call the mutation function
42
43 // which will your mutate function and create a page in your Strapi datastore
44
45 const [createPage] = useMutation(CREATE_PAGE_MUTATION);
46
47 const handleClickOpen = () => {
48
49 setOpen(true);
50
51 };
52
53 const handleClose = () => {
54
55 setTitle("");
56
57 setOpen(false);
58
59 };
60
61 const addPage = async () => {
62
63 // call the create page mutate function
64
65 await createPage({ variables: { title } });
66
67 // call refetch to retrieve the new list of pages
68
69 refetch();
70
71 // close the "create a new page" dialog
72
73 handleClose();
74
75 };
76
77 if (!data) return <div>Loading...</div>;
78
79 const { pages } = data;
80
81 return (
82
83 <section>
84
85 <Grid container direction="column" spacing={2}>
86
87 <Grid item>
88
89 <Typography variant="h3">Welcome</Typography>
90
91 </Grid>
92
93 <Grid item>
94
95 <Typography variant="body1">
96
97 This project uses Strapi + GraphQL + Next.js. It illustrates how to
98
99 use these technologies to create a Notion-like project (with block
100
101 content).
102
103 </Typography>
104
105 </Grid>
106
107 <Grid item>
108
109 <Typography variant="h6">All pages</Typography>
110
111 </Grid>
112
113 <Grid container direction="column" spacing={1}>
114
115 {pages.map((page, index) => (
116
117 <Grid item key={page.id}>
118
119 <div>
120
121 <span>{index + 1}. </span>
122
123 <Link href={`/${page.id}`}>
124
125 <a>{page.title}</a>
126
127 </Link>
128
129 </div>
130
131 </Grid>
132
133 ))}
134
135 </Grid>
136
137 <Grid item>
138
139 <Button color="primary" onClick={handleClickOpen}>
140
141 Add page
142
143 </Button>
144
145 </Grid>
146
147 </Grid>
148
149 <Dialog
150
151 open={open}
152
153 onClose={handleClose}
154
155 aria-labelledby="form-dialog-title"
156
157 >
158
159 <DialogTitle id="form-dialog-title">Add New Page</DialogTitle>
160
161 <DialogContent>
162
163 <TextField
164
165 autoFocus
166
167 margin="dense"
168
169 id="name"
170
171 label="Title"
172
173 type="text"
174
175 fullWidth
176
177 value={title}
178
179 onChange={(event) => setTitle(event.target.value)}
180
181 />
182
183 </DialogContent>
184
185 <DialogActions>
186
187 <Button onClick={handleClose} color="primary">
188
189 Cancel
190
191 </Button>
192
193 <Button onClick={addPage} color="primary">
194
195 Create
196
197 </Button>
198
199 </DialogActions>
200
201 </Dialog>
202
203 </section>
204
205 );
206
207 }
208
209 export async function getStaticProps() {
210
211 return {
212
213 props: {},
214
215 };
216
217 }
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 import { useState } from "react";
2
3 import { useQuery, useMutation } from "graphql-hooks";
4
5 import { useRouter } from "next/router";
6
7 import Typography from "@mui/material/Typography";
8
9 import Button from "@mui/material/Button";
10
11 import Grid from "@mui/material/Grid";
12
13 import TextField from "@mui/material/TextField";
14
15 import Breadcrumbs from "../components/breadcrumbs";
16
17 import Content from "../components/content";
18
19 import {
20
21 PAGE_QUERY,
22
23 DELETE_PAGE_MUTATION,
24
25 UPDATE_PAGE_MUTATION,
26
27 } from "../lib/graphql-query-mutation";
28
29 export default function SinglePage({ id }) {
30
31 // for your textfield
32
33 const [newTitle, setNewTitle] = useState("");
34
35 // edit mode on and off
36
37 const [editTitle, setEditTitle] = useState(false);
38
39 const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
40
41 const [updatePage] = useMutation(UPDATE_PAGE_MUTATION);
42
43 const [deletePage] = useMutation(DELETE_PAGE_MUTATION);
44
45 const router = useRouter();
46
47 if (!data) return <div>Loading</div>;
48
49 const { page } = data;
50
51 const { title } = page;
52
53 const updateTitle = async () => {
54
55 // Update page with new title
56
57 await updatePage({ variables: { id, title: newTitle } });
58
59 // set edit mode to false
60
61 setEditTitle(false);
62
63 setNewTitle("");
64
65 // Get the latest data
66
67 refetch();
68
69 };
70
71 const remove = async () => {
72
73 await deletePage({ variables: { id } });
74
75 //redirect to homepage after deletion.
76
77 router.push(`/`);
78
79 };
80
81 return (
82
83 <Grid container>
84
85 <Breadcrumbs id={id} title={title} />
86
87 <Grid
88
89 container
90
91 direction="row"
92
93 justifyContent="space-between"
94
95 alignItems="center"
96
97 >
98
99 <Grid item>
100
101 {editTitle ? (
102
103 <Grid item>
104
105 <TextField
106
107 label="New Title"
108
109 variant="outlined"
110
111 value={newTitle}
112
113 onChange={(event) => setNewTitle(event.target.value)}
114
115 />
116
117 <Button
118
119 variant="outlined"
120
121 color="primary"
122
123 onClick={updateTitle}
124
125 style={{ marginLeft: 20 }}
126
127 >
128
129 Save
130
131 </Button>
132
133 </Grid>
134
135 ) : (
136
137 <Grid item>
138
139 <Typography variant="h3">{title}</Typography>
140
141 </Grid>
142
143 )}
144
145 </Grid>
146
147 <Grid item>
148
149 <Button
150
151 variant="outlined"
152
153 color="primary"
154
155 onClick={() => setEditTitle(true)}
156
157 style={{ marginRight: 20 }}
158
159 >
160
161 Edit Title
162
163 </Button>
164
165 <Button variant="outlined" color="secondary" onClick={remove}>
166
167 Delete
168
169 </Button>
170
171 </Grid>
172
173 </Grid>
174
175 </Grid>
176
177 );
178
179 }
180
181 {...}
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 import Grid from "@mui/material/Grid";
2
3 import ContentBlock from "./content-block";
4
5 // content.js
6
7 export default function Content({ pageId, refetchPage, content_blocks = [] }) {
8
9 return (
10
11 <Grid container direction="column">
12
13 <Grid item>
14
15 {content_blocks.map((block) => (
16
17 <Grid item key={block.id}>
18
19 <ContentBlock block={block} refetchPage={refetchPage} />
20
21 </Grid>
22
23 ))}
24
25 </Grid>
26
27 </Grid>
28
29 );
30
31 }
1 import Grid from "@mui/material/Grid";
2
3 export default function ContentBlock({ refetchPage, block }) {
4
5 return (
6
7 <Grid container direction="row" alignItems="center">
8
9 <Grid item>
10
11 <div
12
13 dangerouslySetInnerHTML={{ __html: block ? block.content : "" }}
14
15 ></div>
16
17 </Grid>
18
19 </Grid>
20
21 );
22
23 }
Then in your [id].js
file, retrieve your content blocks from your data.
1 import Content from "../components/content";
2
3 export default function SinglePage({ id }) {
4
5 {...}
6
7 const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
8
9 const { page } = data;
10
11 const { content_blocks, title } = page;
12
13 return (
14
15 <Grid container>
16
17 {...}
18
19 <Content
20
21 pageId={id}
22
23 refetchPage={refetch}
24
25 content_blocks={content_blocks}
26
27 />
28
29 </Grid>
30
31 );
32
33 }
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 npm install react-draft-wysiwyg draft-js draftjs-to-html html-to-draftjs
2
3 # or
4
5 yarn add react-draft-wysiwyg draft-js draftjs-to-html html-to-draftjs
Then create an editor.js
file in your component folder.
1 import React, { useState } from "react";
2
3 import { EditorState, convertToRaw, ContentState } from "draft-js";
4
5 import { Editor } from "react-draft-wysiwyg";
6
7 import Button from "@mui/material/Button";
8
9 import draftToHtml from "draftjs-to-html";
10
11 import htmlToDraft from "html-to-draftjs";
12
13 import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
14
15 export default function CustomEditor({ saveBlock, content }) {
16
17 var initalStateEditor;
18
19 // If you want to populate with existing content, turn the html string
20
21 // into Draft blocks and create an editor state
22
23 // else create an empty editor state
24
25 if (content) {
26
27 const contentBlock = htmlToDraft(content);
28
29 const contentState = ContentState.createFromBlockArray(
30
31 contentBlock.contentBlocks
32
33 );
34
35 initalStateEditor = EditorState.createWithContent(contentState);
36
37 } else {
38
39 initalStateEditor = EditorState.createEmpty();
40
41 }
42
43 const [editorState, setEditorState] = useState(initalStateEditor);
44
45 const onEditorStateChange = (editorState) => {
46
47 setEditorState(editorState);
48
49 };
50
51 // convert draft blocks back to a raw HTML string
52
53 const onEditorSave = () => {
54
55 const newContent = draftToHtml(
56
57 convertToRaw(editorState.getCurrentContent())
58
59 );
60
61 saveBlock(newContent);
62
63 };
64
65 return (
66
67 <div>
68
69 <div>
70
71 <Editor
72
73 editorState={editorState}
74
75 wrapperClassName="demo-wrapper"
76
77 editorClassName="demo-editor"
78
79 onEditorStateChange={onEditorStateChange}
80
81 />
82
83 </div>
84
85 <Button variant="outlined" color="secondary" onClick={onEditorSave}>
86
87 Save
88
89 </Button>
90
91 </div>
92
93 );
94
95 }
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 import { useState } from "react";
2
3 import { useMutation } from "graphql-hooks";
4
5 import dynamic from "next/dynamic";
6
7 import Grid from "@mui/material/Grid";
8
9 import Button from "@mui/material/Button";
10
11 import ContentBlock from "./content-block";
12
13 const Editor = dynamic(() => import("./editor"));
14
15 import { CREATE_BLOCKS_MUTATION } from "../lib/graphql-query-mutation";
16
17 export default function Content({ pageId, refetchPage, content_blocks = [] }) {
18
19 // toggle add block mode or not
20
21 const [openEditor, setOpenEditor] = useState(false);
22
23 const [createContentBlock] = useMutation(CREATE_BLOCKS_MUTATION);
24
25 const addBlock = async (content) => {
26
27 // create a new block with your string content and specify to
28
29 // which page the block belongs to.
30
31 await createContentBlock({ variables: { content, pageId } });
32
33 setOpenEditor(false);
34
35 // refetch the page to get the latest data
36
37 refetchPage();
38
39 };
40
41 return (
42
43 <Grid container direction="column">
44
45 <Grid item>
46
47 {content_blocks.map((block) => (
48
49 <Grid item key={block.id}>
50
51 <ContentBlock block={block} refetchPage={refetchPage} />
52
53 </Grid>
54
55 ))}
56
57 </Grid>
58
59 <Grid item>
60
61 {openEditor ? (
62
63 <Editor saveBlock={addBlock} />
64
65 ) : (
66
67 <Button color="primary" onClick={() => setOpenEditor(true)}>
68
69 Add content block
70
71 </Button>
72
73 )}
74
75 </Grid>
76
77 </Grid>
78
79 );
80
81 }
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 import { useMutation } from "graphql-hooks";
2
3 import { useState } from "react";
4
5 import dynamic from "next/dynamic";
6
7 import Grid from "@mui/material/Grid";
8
9 import IconButton from "@mui/material/IconButton";
10
11 import MoreVertIcon from "@mui/icons-material/MoreVert";
12
13 import Menu from "@mui/material/Menu";
14
15 import MenuItem from "@mui/material/MenuItem";
16
17 const Editor = dynamic(() => import("./editor"));
18
19 import {
20
21 DELETE_BLOCK_MUTATION,
22
23 UPDATE_BLOCKS_MUTATION,
24
25 } from "../lib/graphql-query-mutation";
26
27 export default function ContentBlock({ refetchPage, block }) {
28
29 // edit mode. If true, the editor appears. If not, the content is
30
31 // rendered.
32
33 const [openEditor, setOpenEditor] = useState(false);
34
35 const [deleteContentBlock] = useMutation(DELETE_BLOCK_MUTATION);
36
37 const [updateContentBlock] = useMutation(UPDATE_BLOCKS_MUTATION);
38
39 // for the edit/delete menu
40
41 const [anchorEl, setAnchorEl] = useState(null);
42
43 const { id } = block;
44
45 //when the three dot icon is clicked, set the anchor so the menu knows where to open
46
47 const handleClick = (event) => {
48
49 setAnchorEl(event.currentTarget);
50
51 };
52
53 const handleClose = () => {
54
55 setAnchorEl(null);
56
57 };
58
59 const handleDelete = async () => {
60
61 await deleteContentBlock({ variables: { id } });
62
63 refetchPage();
64
65 handleClose();
66
67 };
68
69 const handleEdit = () => {
70
71 handleClose();
72
73 setOpenEditor(true);
74
75 };
76
77 const saveBlock = async (content) => {
78
79 await updateContentBlock({ variables: { id, content } });
80
81 setOpenEditor(false);
82
83 refetchPage();
84
85 };
86
87 // if edit more is true
88
89 if (openEditor)
90
91 return (
92
93 <div>
94
95 <Editor content={block.content} saveBlock={saveBlock} />
96
97 </div>
98
99 );
100
101 //if edit mode is false
102
103 return (
104
105 <Grid container direction="row" alignItems="center">
106
107 <Grid item>
108
109 <IconButton
110
111 aria-label="options"
112
113 aria-controls="simple-menu"
114
115 aria-haspopup="true"
116
117 onClick={handleClick}
118
119 size="small"
120
121 >
122
123 <MoreVertIcon />
124
125 </IconButton>
126
127 <Menu
128
129 id="simple-menu"
130
131 anchorEl={anchorEl}
132
133 keepMounted
134
135 open={Boolean(anchorEl)}
136
137 onClose={handleClose}
138
139 >
140
141 <MenuItem onClick={handleEdit}>Edit</MenuItem>
142
143 <MenuItem onClick={handleDelete}>Delete</MenuItem>
144
145 </Menu>
146
147 </Grid>
148
149 <Grid item>
150
151 <div
152
153 dangerouslySetInnerHTML={{ __html: block ? block.content : "" }}
154
155 ></div>
156
157 </Grid>
158
159 </Grid>
160
161 );
162
163 }
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.