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:
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.
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 initialise it.
1 import { useMemo } from "react";
2 import { GraphQLClient } from "graphql-hooks";
3 import memCache from "graphql-hooks-memcache";
4
5 let graphQLClient;
6
7 //Create a GraphQL client by connecting to your API
8 function createClient(initialState) {
9 return new GraphQLClient({
10 ssrMode: typeof window === "undefined",
11 url: "http://localhost:1337/graphql", // Server URL (must be absolute)
12 cache: memCache({ initialState }),
13 });
14 }
15
16 // Initialize your GraphQL client or return the existing one
17 export function initializeGraphQL(initialState = null) {
18 const _graphQLClient = graphQLClient ?? createClient(initialState);
19 // For SSG and SSR always create a new GraphQL Client
20 if (typeof window === "undefined") return _graphQLClient;
21 // Create the GraphQL Client once in the client
22 if (!graphQLClient) graphQLClient = _graphQLClient;
23 return _graphQLClient;
24 }
25
26 export function useGraphQLClient(initialState) {
27 const store = useMemo(() => initializeGraphQL(initialState), [initialState]);
28 return store;
29 }
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 import { ClientContext } from "graphql-hooks";
3 import { useGraphQLClient } from "../lib/graphql-client";
4
5 export default function App({ Component, pageProps }) {
6 const graphQLClient = useGraphQLClient();
7 return (
8 <ClientContext.Provider value={graphQLClient}>
9 <Component {...pageProps} />
10 </ClientContext.Provider>
11 );
12 }
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 query getAllPages{
3 pages {
4 data {
5 id
6 attributes {
7 title
8 }
9 }
10 }
11 }
12 `;
13 export const PAGE_QUERY = `
14 query getOnePage($id: ID!){
15 page(id: $id){
16 data {
17 id
18 attributes {
19 title
20 content_blocks {
21 data {
22 id
23 attributes {
24 content
25 }
26 }
27 }
28 }
29 }
30 }
31 }
32 `;
33 export const CREATE_PAGE_MUTATION = `
34 mutation createPage ($title: String!) {
35 createPage(data: { title: $title }) {
36 data {
37 id
38 attributes {
39 title
40 }
41 }
42 }
43 }
44 `;
45 export const UPDATE_PAGE_MUTATION = `
46 mutation updatePage ($id: ID!, $title: String!) {
47 updatePage (id: $id, data: { title: $title}) {
48 data {
49 id
50 attributes {
51 title
52 content_blocks {
53 data {
54 id
55 attributes {
56 content
57 }
58 }
59 }
60 }
61 }
62 }
63 }
64 `;
65 export const DELETE_PAGE_MUTATION = `
66 mutation deletePage ($id: ID!) {
67 deletePage (id: $id) {
68 data {
69 id
70 attributes {
71 title
72 }
73 }
74 }
75 }
76 `;
77 export const CREATE_BLOCKS_MUTATION = `
78 mutation createContentBlock ($content: String!, $pageId: ID!){
79 createContentBlock(
80 data: {
81 content: $content
82 page: $pageId
83 }
84 ){
85 data {
86 id
87 attributes {
88 content
89 page {
90 data {
91 id
92 attributes {
93 title
94 }
95 }
96 }
97 }
98 }
99 }
100 }
101 `;
102 export const UPDATE_BLOCKS_MUTATION = `
103 mutation updateContentBlock ($id: ID!, $content: String!) {
104 updateContentBlock(id: $id, data: { content: $content}){
105 data {
106 id
107 attributes {
108 content
109 }
110 }
111 }
112 }
113 `;
114 export const DELETE_BLOCK_MUTATION = `
115 mutation deleteContentBlock ($id: ID!) {
116 deleteContentBlock(id: $id) {
117 data {
118 id
119 attributes {
120 content
121 }
122 }
123 }
124 }
125 `;
By default, Strapi writes a draft for each entry before publishing it. But, drafted entries will not be exposed by your API for display. Only published entries are visible. So, before you head on to creating, updating pages or content blocks using GraphQL, first disable entry drafting. To do that, headover to PLUGINS > Content-Type Builder > COLLECTION TYPES. Select a collection type to disable drafting. In this context, set Page > ADVANCED SETTINGS > Draft/Publish to False.
Done?
Awesome!
Don’t forget to do the same to the Content Block collection type. Now, proceed to building the rest of the clone.
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 v4 and MUI v5 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:
MUI v5:
npm install @mui/material @emotion/react @emotion/styled @mui/icons-material
# or
yarn add @mui/material @emotion/react @emotion/styled @mui/icons-material
MUI v4:
npm install @material-ui/core @material-ui/icons
# or
yarn add @material-ui/core @material-ui/icons
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 import { ClientContext } from "graphql-hooks";
3 import { useGraphQLClient } from "../lib/graphql-client";
4 import CssBaseline from "@mui/material/CssBaseline";
5 import Container from "@mui/material/Container";
6
7 export default function App({ Component, pageProps }) {
8 const graphQLClient = useGraphQLClient();
9 return (
10 <CssBaseline>
11 <Container maxWidth="lg">
12 <ClientContext.Provider value={graphQLClient}>
13 <Component {...pageProps} />
14 </ClientContext.Provider>
15 </Container>
16 </CssBaseline>
17 );
18 }
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 import { useState } from "react";
3 import Link from "next/link";
4 import Typography from "@mui/material/Typography";
5 import Grid from "@mui/material/Grid";
6 import {
7 ALL_PAGES_QUERY,
8 } from "../lib/graphql-query-mutation";
9
10 export default function Home() {
11 //use your ALL_PAGES_QUERY query to retrieve your pages
12 const { data } = useQuery(ALL_PAGES_QUERY);
13
14 if (!data) return <div>Loading...</div>;
15 const { pages } = data;
16
17 return (
18 <section>
19 <Grid container direction="column" spacing={2}>
20 <Grid item>
21 <Typography variant="h3">Welcome</Typography>
22 </Grid>
23 <Grid item>
24 <Typography variant="body1">
25 This project uses Strapi + GraphQL + Next.js. It illustrates how to use these technologies to create a Notion-like project (with block content).
26 </Typography>
27 </Grid>
28 <Grid item>
29 <Typography variant="h6">All pages</Typography>
30 </Grid>
31 <Grid container direction="column" spacing={1}>
32 {pages.data.map((page, index) => (
33 <Grid item key={page.id}>
34 <div>
35 <span>{index + 1}. </span>
36 <Link href={`/${page.id}`}>
37 <a>{page.attributes.title}</a>
38 </Link>
39 </div>
40 </Grid>
41 ))}
42 </Grid>
43 </Grid>
44 </section>
45 )
46 }
47
48 export async function getStaticProps() {
49 return {
50 props: {},
51 };
52 }
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 import Typography from "@mui/material/Typography";
3 import Grid from "@mui/material/Grid";
4
5 import Breadcrumbs from "../components/breadcrumbs";
6
7 import {
8 PAGE_QUERY,
9 } from "../lib/graphql-query-mutation";
10
11 export default function SinglePage({ id }) {
12 const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
13
14 if (!data) return <div>Loading</div>;
15
16 return (
17 <Grid container>
18 <Breadcrumbs id={id} title={data && data.page.data.attributes.title} />
19 <Grid
20 container
21 direction="row"
22 justifyContent="space-between"
23 alignItems="center"
24 >
25 <Grid item>
26 <Typography variant="h3">
27 {data && data.page.data.attributes.title}
28 </Typography>
29 </Grid>
30 </Grid>
31 </Grid>
32 );
33 }
34
35 export async function getServerSideProps(context) {
36 const { id } = context.params;
37 return {
38 props: { id },
39 };
40 }
You can also create a Breadcrumbs
component to add more navigation to your application:
1 import Breadcrumbs from "@mui/material/Breadcrumbs";
2 import Link from "@mui/material/Link";
3
4 export default function CustomBreadcrumbs({ id, title }) {
5 return (
6 <Breadcrumbs aria-label="breadcrumb" style={{ padding: "20px 0px" }}>
7 <Link color="inherit" href="/">
8 All Pages
9 </Link>
10 <Link color="textPrimary" href={`/${id}`} aria-current="page">
11 {title}
12 </Link>
13 </Breadcrumbs>
14 );
15 }
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.
Note:
1 import { useQuery, useMutation } from "graphql-hooks";
2 import { useState } from "react";
3 import Link from "next/link";
4 import Typography from "@mui/material/Typography";
5 import Grid from "@mui/material/Grid";
6 import Button from "@mui/material/Button";
7 import TextField from "@mui/material/TextField";
8 import Dialog from "@mui/material/Dialog";
9 import DialogActions from "@mui/material/DialogActions";
10 import DialogContent from "@mui/material/DialogContent";
11 import DialogTitle from "@mui/material/DialogTitle";
12
13 import {
14 ALL_PAGES_QUERY,
15 CREATE_PAGE_MUTATION,
16 } from "../lib/graphql-query-mutation";
17
18 export default function Home() {
19 const [open, setOpen] = useState(false);
20 const [title, setTitle] = useState("");
21
22 // refetch allows you to call the query again and refresh data
23 const { data, refetch } = useQuery(ALL_PAGES_QUERY);
24
25 //createPage function allows you to call the mutation function
26 // which will your mutate function and create a page in your Strapi datastore
27 const [createPage] = useMutation(CREATE_PAGE_MUTATION);
28 const handleClickOpen = () => {
29 setOpen(true);
30 };
31
32 const handleClose = () => {
33 setTitle("");
34 setOpen(false);
35 };
36
37 const addPage = async () => {
38 // call the create page mutate function
39 await createPage({ variables: { title } });
40 // call refetch to retrieve the new list of pages
41 refetch();
42 // close the "create a new page" dialog
43 handleClose();
44 };
45
46 if (!data) return <div>Loading...</div>;
47
48 const { pages } = data;
49
50 return (
51 <section>
52 <Grid container direction="column" spacing={2}>
53 <Grid item>
54 <Typography variant="h3">Welcome</Typography>
55 </Grid>
56 <Grid item>
57 <Typography variant="body1">
58 This project uses Strapi + GraphQL + Next.js. It illustrates how to use these technologies to create a Notion-like project (with block content).
59 </Typography>
60 </Grid>
61
62 <Grid item>
63 <Typography variant="h6">All pages</Typography>
64 </Grid>
65
66 <Grid container direction="column" spacing={1}>
67 {pages.data.map((page, index) => (
68 <Grid item key={page.id}>
69 <div>
70 <span>{index + 1}. </span>
71 <Link href={`/${page.id}`}>
72 <a>{page.attributes.title}</a>
73 </Link>
74 </div>
75 </Grid>
76 ))}
77 </Grid>
78 <Grid item>
79 <Button color="primary" onClick={handleClickOpen}>
80 Add page
81 </Button>
82 </Grid>
83 </Grid>
84 <Dialog
85 open={open}
86 onClose={handleClose}
87 aria-labelledby="form-dialog-title"
88 >
89 <DialogTitle id="form-dialog-title">Add New Page</DialogTitle>
90 <DialogContent>
91 <TextField
92 autoFocus
93 margin="dense"
94 id="name"
95 label="Title"
96 type="text"
97 fullWidth
98 value={title}
99 onChange={(event) => setTitle(event.target.value)}
100 />
101 </DialogContent>
102 <DialogActions>
103 <Button onClick={handleClose} color="primary">
104 Cancel
105 </Button>
106 <Button onClick={addPage} color="primary">
107 Create
108 </Button>
109 </DialogActions>
110 </Dialog>
111 </section>
112 );
113 }
114
115 export async function getStaticProps() {
116 return {
117 props: {},
118 };
119 }
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 import { useQuery, useMutation } from "graphql-hooks";
3 import { useRouter } from "next/router";
4 import Typography from "@mui/material/Typography";
5 import Button from "@mui/material/Button";
6 import Grid from "@mui/material/Grid";
7 import TextField from "@mui/material/TextField";
8 import Breadcrumbs from "../components/breadcrumbs";
9 import Content from "../components/content";
10
11 import {
12 PAGE_QUERY,
13 DELETE_PAGE_MUTATION,
14 UPDATE_PAGE_MUTATION,
15 } from "../lib/graphql-query-mutation";
16
17 export default function SinglePage({ id }) {
18
19 // for your textfield
20 const [newTitle, setNewTitle] = useState("");
21 // edit mode on and off
22 const [editTitle, setEditTitle] = useState(false);
23 const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
24 const [updatePage] = useMutation(UPDATE_PAGE_MUTATION);
25 const [deletePage] = useMutation(DELETE_PAGE_MUTATION);
26 const router = useRouter();
27
28 if (!data) return <div>Loading</div>;
29
30 const updateTitle = async () => {
31 // Update page with new title
32 await updatePage({ variables: { id, title: newTitle } });
33 // set edit mode to false
34 setEditTitle(false);
35 setNewTitle("");
36 // Get the latest data
37 refetch();
38 };
39
40 const remove = async () => {
41 await deletePage({ variables: { id } });
42 //redirect to homepage after deletion.
43 router.push(`/`);
44 };
45
46 return (
47 <Grid container>
48 <Breadcrumbs id={id} title={data && data.page.data.attributes.title} />
49 <Grid
50 container
51 direction="row"
52 justifyContent="space-between"
53 alignItems="center"
54 >
55 <Grid item>
56 {editTitle ? (
57 <Grid item>
58 <TextField
59 label="New Title"
60 variant="outlined"
61 value={newTitle}
62 onChange={(event) => setNewTitle(event.target.value)}
63 />
64 <Button
65 variant="outlined"
66 color="primary"
67 onClick={updateTitle}
68 style={{ marginLeft: 20 }}
69 >
70 Save
71 </Button>
72 </Grid>
73 ) : (
74 <Grid item>
75 <Typography variant="h3">
76 {data && data.page.data.attributes.title}
77 </Typography>
78 </Grid>
79 )}
80 </Grid>
81 <Grid item>
82 <Button
83 variant="outlined"
84 color="primary"
85 onClick={() => setEditTitle(true)}
86 style={{ marginRight: 20 }}
87 >
88 Edit Title
89 </Button>
90 <Button variant="outlined" color="secondary" onClick={remove}>
91 Delete
92 </Button>
93 </Grid>
94 </Grid>
95 </Grid>
96 );
97 }
98
99 {...}
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 import ContentBlock from "./content-block";
3
4 // content.js
5 export default function Content({ pageId, refetchPage, content_blocks = [] }) {
6
7 return (
8 <Grid container direction="column">
9 <Grid item>
10 {content_blocks.map((block) => (
11 <Grid item key={block.id}>
12 <ContentBlock block={block} refetchPage={refetchPage} />
13 </Grid>
14 ))}
15 </Grid>
16 </Grid>
17 );
18 }
19
20
21 import Grid from "@mui/material/Grid";
22
23 export default function ContentBlock({ refetchPage, id, block }) {
24 return (
25 <Grid container direction="row" alignItems="center">
26 <Grid item>
27 <div
28 dangerouslySetInnerHTML={{ __html: block ? block.content : "" }}
29 ></div>
30 </Grid>
31 </Grid>
32 );
33 }
Then in your [id].js
file, retrieve your content blocks from your data.
1 import Content from "../components/content";
2 export default function SinglePage({ id }) {
3
4 {...}
5 const { data, refetch } = useQuery(PAGE_QUERY, { variables: { id } });
6
7 return (
8 <Grid container>
9 {...}
10 <Content
11 pageId={id}
12 refetchPage={refetch}
13 content_blocks={data && data.page.data.attributes.content_blocks.data}
14 />
15 </Grid>
16 );
17 }
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:
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 import React, { useState } from "react";
2 import { EditorState, convertToRaw, ContentState } from "draft-js";
3 import Button from "@mui/material/Button";
4 import draftToHtml from "draftjs-to-html";
5
6 const htmlToDraft = typeof window === 'object' && require('html-to-draftjs').default;
7 import "react-draft-wysiwyg/dist/react-draft-wysiwyg.css";
8
9 import dynamic from 'next/dynamic';
10 const Editor = dynamic(
11 () => import('react-draft-wysiwyg').then(mod => mod.Editor),
12 { ssr: false }
13 )
14
15 export default function CustomEditor({ saveBlock, content }) {
16 var initalStateEditor;
17 // If you want to populate with existing content, turn the html string
18 // into Draft blocks and create an editor state
19 // else create an empty editor state
20
21 if (content) {
22 const contentBlock = htmlToDraft(content);
23 const contentState = ContentState.createFromBlockArray(
24 contentBlock.contentBlocks
25 );
26 initalStateEditor = EditorState.createWithContent(contentState);
27 } else {
28 initalStateEditor = EditorState.createEmpty();
29 }
30
31 const [editorState, setEditorState] = useState(initalStateEditor);
32
33 const onEditorStateChange = (editorState) => {
34 setEditorState(editorState);
35 };
36
37 // convert draft blocks back to a raw HTML string
38 const onEditorSave = () => {
39 const newContent = draftToHtml(
40 convertToRaw(editorState.getCurrentContent())
41 );
42 saveBlock(newContent);
43 };
44
45 return (
46 <div>
47 <div>
48 <Editor
49 editorState={editorState}
50 wrapperClassName="demo-wrapper"
51 editorClassName="demo-editor"
52 onEditorStateChange={onEditorStateChange}
53 />
54
55 </div>
56 <Button variant="outlined" color="secondary" onClick={onEditorSave}>
57 Save
58 </Button>
59 </div>
60 );
61 }
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 import { useMutation } from "graphql-hooks";
3 import dynamic from "next/dynamic";
4 import Grid from "@mui/material/Grid";
5 import Button from "@mui/material/Button";
6 import ContentBlock from "./content-block";
7
8 const Editor = dynamic(() => import("./editor"));
9 import { CREATE_BLOCKS_MUTATION } from "../lib/graphql-query-mutation";
10
11 export default function Content({ pageId, refetchPage, content_blocks = [] }) {
12
13 // toggle add block mode or not
14 const [openEditor, setOpenEditor] = useState(false);
15 const [createContentBlock] = useMutation(CREATE_BLOCKS_MUTATION);
16 const addBlock = async (content) => {
17
18 // create a new block with your string content and specify to
19 // which page the block belongs to.
20
21 await createContentBlock({ variables: { content, pageId } });
22
23 setOpenEditor(false);
24
25 // refetch the page to get the latest data
26 refetchPage();
27 };
28
29 return (
30 <Grid container direction="column">
31 <Grid item>
32 {content_blocks.map((block) => (
33 <Grid item key={block.id}
34 <ContentBlock block={block.attributes} refetchPage={refetchPage} id={block.id}/>
35 </Grid>
36 ))}
37 </Grid>
38 <Grid item>
39 {openEditor ? (
40 <Editor saveBlock={addBlock} />
41 ) : (
42 <Button color="primary" onClick={() => setOpenEditor(true)}>
43 Add content block
44 </Button>
45 )}
46 </Grid>
47 </Grid>
48 );
49 }
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 import { useState } from "react";
3 import dynamic from "next/dynamic";
4 import Grid from "@mui/material/Grid";
5 import IconButton from "@mui/material/IconButton";
6 import MoreVertIcon from "@mui/icons-material/MoreVert";
7 import Menu from "@mui/material/Menu";
8 import MenuItem from "@mui/material/MenuItem";
9
10 const Editor = dynamic(() => import("./editor"));
11
12 import {
13 DELETE_BLOCK_MUTATION,
14 UPDATE_BLOCKS_MUTATION,
15 } from "../lib/graphql-query-mutation";
16
17 export default function ContentBlock({ refetchPage, id, block }) {
18 // edit mode. If true, the editor appears. If not, the content is
19 // rendered.
20 const [openEditor, setOpenEditor] = useState(false);
21 const [deleteContentBlock] = useMutation(DELETE_BLOCK_MUTATION);
22 const [updateContentBlock] = useMutation(UPDATE_BLOCKS_MUTATION);
23
24 // for the edit/delete menu
25 const [anchorEl, setAnchorEl] = useState(null);
26 const { id } = block;
27
28 //when the three dot icon is clicked, set the anchor so the menu knows where to open
29 const handleClick = (event) => {
30 setAnchorEl(event.currentTarget);
31 };
32
33 const handleClose = () => {
34 setAnchorEl(null);
35 };
36
37 const handleDelete = async () => {
38 await deleteContentBlock({ variables: { id } });
39 refetchPage();
40 handleClose();
41 };
42
43 const handleEdit = () => {
44 handleClose();
45 setOpenEditor(true);
46 };
47
48 const saveBlock = async (content) => {
49 await updateContentBlock({ variables: { id, content } });
50 setOpenEditor(false);
51 refetchPage();
52 };
53
54 // if edit more is true
55 if (openEditor)
56 return (
57 <div>
58 <Editor content={block.content} saveBlock={saveBlock} />
59 </div>
60 );
61
62 // if edit mode is false
63 return (
64 <Grid container direction="row" alignItems="center">
65 <Grid item>
66 <IconButton
67 aria-label="options"
68 aria-controls="simple-menu"
69 aria-haspopup="true"
70 onClick={handleClick}
71 size="small"
72 >
73 <MoreVertIcon />
74 </IconButton>
75 <Menu
76 id="simple-menu"
77 anchorEl={anchorEl}
78 keepMounted
79 open={Boolean(anchorEl)}
80 onClose={handleClose}
81 >
82 <MenuItem onClick={handleEdit}>Edit</MenuItem>
83 <MenuItem onClick={handleDelete}>Delete</MenuItem>
84 </Menu>
85 </Grid>
86 <Grid item>
87 <div
88 dangerouslySetInnerHTML={{ __html: block ? block.content : "" }}
89 ></div>
90 </Grid>
91 </Grid>
92 );
93 }
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 frontend of the codebase can be found this repo and the backend here.
Student Developer ~ Yet Another Open Source Guy ~ JavaScript/TypeScript Developer & a Tech Outlaw...