Supabase + NextJS - Authentication & CRUD API

Supabase has been growing by 100x with 2k searches per month. If you’re wondering what supabase is or wanting to learn about it. you’re in the right place. This article is to explain about basics of Supabase and building a simple application with Supabase, Next.js.

Supabase Growth

What is Supabase?

According to the official documentation,

Supabase is an open source Firebase alternative. It provides all the backend services you need to build a product. Supabase uses Postgres database with real-time capabilities. Basically, supabase provides an interface to manage postgres database that you can use to create table and insert, edit and delete data in the table.

We can use REST API or client libraries from supabase to access the data in the postgres database. Supabase is not just about accessing the database. it also provides some solutions out of the box such as Authentication, File Storage and Real-time capabilities.

Let’s create a new project in Supabase that creates a credentials to access the database.

  1. Go to https://app.supabase.io/

  2. Click on New Project

    Creating New Project

  3. It will launch the database and required credential to access them.

Once you create the database and credentials to access it, let’s create a Next.js application and integrate supabase into it.

Supabase + Next.js

To give a taste of what we are going to build in this application. Here’s a demo to explain this simple application.

https://github.com/ganeshmani/supabase-examples/tree/main/NextjsCRUD

Let’s create a Next.js application and install supabase-js client library to integration Supabase.

1npx create-next-app NextjsCRUD --ts
2cd NextjsCRUD

Then install the supabase client library to access supabase in the Next.js application.

1npm install @supabase/supabase-js

Once you install the dependancy, add environment variables for Supabase url and supabase key to access supabase by creating .env.local in the project root directory.

1NEXT_PUBLIC_SUPABASE_URL = YOUR_SUPABASE_URL
2NEXT_PUBLIC_SUPABASE_ANON_KEY = YOUR_SUPABASE_ANON_KEY

Now that you have API credentials, let’s create a helper to initialize the supabase client. This supabase instances and credentials are exposed on the browser and it’s safer since we have Row level security enabled in our database.

create lib/supabaseClient.js and add the following code,

1import { createClient } from "@supabase/supabase-js"
2
3const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""
4const supabaseToken = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""
5
6export const supabaseClient = createClient(supabaseUrl, supabaseToken)

It creates a supabase client with supabase url and supabase token that you provide via environment variable. you can use the supabase client wherever you need to access the database.

Styling

For styles, we are going to use Chakra UI. It’s opinionated. you can use your own preferred styling like TailwindCSS, Material UI etc.

To setup chakra UI, install the dependancy @chakra-ui/react

1npm install @chakra-ui/react

Then, set it up inside the _app.tsx ,

1import "../styles/globals.css"
2import type { AppProps } from "next/app"
3import { ChakraProvider } from "@chakra-ui/react"
4
5function MyApp({ Component, pageProps }: AppProps) {
6 return (
7 <ChakraProvider>
8 <Component {...pageProps} />
9 </ChakraProvider>
10 )
11}
12
13export default MyApp

Authentication

Authentication using Supabase is straightforward and supabase provides a solution out of the box. Supabase provides several ways to authenticate users. They are,

  • Email & Password.
  • Magic Links(one-click logins).
  • Social providers(Facebook, Google, Twitter etc).
  • Phone logins.

For authentication, we are going to use Magic links, so users can sign in with their email without using passwords.

login.tsx

1import { useState } from "react";
2import type { NextPage } from "next";
3import { supabaseClient } from "../lib/supabaseClient";
4
5import {
6 Flex,
7 Box,
8 FormControl,
9 FormLabel,
10 Input,
11 Checkbox,
12 Stack,
13 Link,
14 Button,
15 Heading,
16 Text,
17 useColorModeValue,
18} from "@chakra-ui/react";
19
20const Login: NextPage = () => {
21 const [loading, setLoading] = useState(false);
22 const [email, setEmail] = useState("");
23
24 const handleLogin = async (email: string) => {
25 try {
26 setLoading(true);
27 const { error } = await supabaseClient.auth.signIn({ email });
28 if (error) throw error;
29 alert("Check your email for the login link!");
30 } catch (error: any) {
31 alert(error.error_description || error.message);
32 } finally {
33 setLoading(false);
34 }
35 };
36 return (
37 <Flex
38 minH={"100vh"}
39 align={"center"}
40 justify={"center"}
41 bg={useColorModeValue("gray.50", "gray.800")}
42 >
43 <Stack spacing={8} mx={"auto"} maxW={"lg"} py={12} px={6}>
44 <Stack align={"center"}>
45 <Heading fontSize={"4xl"}>Sign in to your account</Heading>
46 </Stack>
47 <Box
48 rounded={"lg"}
49 bg={useColorModeValue("white", "gray.700")}
50 boxShadow={"lg"}
51 p={8}
52 >
53 <Stack spacing={4}>
54 <FormControl id="email">
55 <FormLabel>Email address</FormLabel>
56 <Input
57 type="email"
58 placeholder="Enter your email"
59 value={email}
60 onChange={(e) => setEmail(e.target.value)}
61 />
62 </FormControl>
63 <Stack spacing={10}>
64 <Button
65 isLoading={loading}
66 bg={"blue.400"}
67 onClick={(e) => {
68 e.preventDefault();
69 handleLogin(email);
70 }}
71 color={"white"}
72 _hover={{
73 bg: "blue.500",
74 }}
75 >
76 Send magic link
77 </Button>
78 </Stack>
79 </Stack>
80 </Box>
81 </Stack>
82 </Flex>
83 );
84};
85export default Login;

Here, we have a input to capture the email address and button to handle the login functionality.

Signin Account

Once user clicks the Send magic link button, we call the handleLogin function that uses supbase auth signin.

1const handleLogin = async (email: string) => {
2 try {
3 setLoading(true);
4 const { error } = await supabaseClient.auth.signIn({ email }); //Supabase Auth signin
5 if (error) throw error;
6 alert("Check your email for the login link!");
7 } catch (error: any) {
8 alert(error.error_description || error.message);
9 } finally {
10 setLoading(false);
11 }
12 };

Then, user receives a magic link in their email to login to the application.

Email Magic Link

It will redirect the user into the application’s home page. We can also configure the redirect URL via supabase auth settings. It’ll update the redirection after the user clicks the login link.

To configure redirect URL for post authentication,

  1. Go to projects

    Supabase Dashboard

  2. Click on Auth and click on settings to change the default redirect URL.

    Supabase Settings

You can also access the users list who signed in your application via users section in supabase.

Supabase Users List

Once the user login, supabase stores the user information in the session. we can access the user information via supabase sessions. We need that information to validate the user everytime when they visit the protected routes. it can be home page or dashboard.

For that, let’s create a custom hooks to get user information from supabase session and access it via user variable. In that way, wherever we need to user information, we don’t need to write the same functionality inside the component again.

To learn about custom hooks in react: refer the documentation here

create hooks/useUser.ts and add the following code,

1import { useState, useEffect } from "react"
2import { supabaseClient } from "../lib/supabaseClient"
3const useUser = () => {
4 const [user, setUser] = useState < any > null
5 const [isLoading, setLoading] = useState < boolean > true
6 const [token, setToken] = useState < any > null
7
8 useEffect(() => {
9 const supabaseSession = supabaseClient.auth.session()
10
11 if (supabaseSession?.user?.id) {
12 setUser(supabaseSession.user)
13 setToken(supabaseSession.access_token)
14 }
15
16 setLoading(false)
17
18 supabaseClient.auth.onAuthStateChange((_event, session) => {
19 if (session?.user?.id) {
20 setUser(session.user)
21 setToken(session.access_token)
22 }
23 setLoading(false)
24 })
25 }, [supabaseClient])
26
27 return {
28 user,
29 isLoading,
30 token,
31 }
32}
33
34export default useUser

Here, we are using useState to store user info, loading state and token. Initially, we have loading: true to indicate the component that it’s loading. Once we get user information from supabase session. we store them into user state in the hooks and set loading: false.

Now, let’s add this step into index.tsx here to verify if the user is authenticated or not. if not, we can redirect to /login page.

1const Home: NextPage = () => {
2 const router = useRouter()
3 const { user, isLoading, token } = useUser()
4
5 useEffect(() => {
6 if (!user && !isLoading) {
7 router.push("/login")
8 }
9 }, [user, isLoading, router])
10
11 if (isLoading) {
12 return <div>Loading</div>
13 } else {
14 return (
15 <Layout>
16 <Container>//CRUD API - Todo Container</Container>
17
18 <Toaster />
19 </Layout>
20 )
21 }
22}
23
24export default Home

Now, the user is logged in, let’s see how to implement CRUD API using Supabase in Next.js application.

Supabase + Nextjs - CRUD API

Since this tutorial is to learn how to implement CRUD functionality using Supabase, Next.js. we will go with a classic example. i.e., Todo Application.

First and foremost, create a new table in the postgres database. We can use supabase UI to create a table.

  1. Go to Project
  2. Click on the Table Editor(Left side Menu)

Create New Project UI

  1. Click on Create a new table and add the following column inside the table.

    Edit Supabase Table

Once you add and create a table. we can use it via supabase client in the react application. whenever you need a new column or change a column, you can edit the table and update it.

TODO UI

Application UI

Here’s a simple UI behaviour to understand the functionality. When you add a new item and enter it, it will create a todo item. it will be added into the list automatically( we will use a react-query to achieve that). you can edit or delete a todo item from the list.

If you’re new to React Query, React Query is a manage the server state of client side application. it is often described as data-fetching library of React. it makes fetching, caching, synchronizing and updating server state in the React application.

create Todos/index.tsx to handle the list and Todos/Add.tsx for handling inserting todo item. Once you create those components, import them into index.tsx.

1return (
2 <Layout>
3 <Container>
4 <AddTodo />
5 <Todos />
6 </Container>
7
8 <Toaster />
9 </Layout>
10)

Todo/Add.tsx

1import { useState, useEffect } from "react"
2import { useMutation, useQueryClient } from "react-query"
3import toast, { Toaster } from "react-hot-toast"
4import { User } from "@supabase/supabase-js"
5import { Container, Input, ListItem, OrderedList } from "@chakra-ui/react"
6import { supabaseClient } from "@lib/supabaseClient"
7import useUser from "@hooks/useUser"
8
9const AddTodo = () => {
10 const [currentItem, setCurrentItem] = useState < string > ""
11 const { user } = useUser()
12 const queryClient = useQueryClient()
13
14 const { mutate: addTodoMutation } = useMutation(
15 async (payload: { item: string, user: User }) => {
16 const { data, error } = await supabaseClient.from("todos").insert([
17 {
18 name: payload.item,
19 user_id: payload.user.id,
20 },
21 ])
22
23 if (error) {
24 console.log("Error", error)
25 }
26 return data
27 },
28 {
29 onSuccess: () => {
30 toast.success("Item Added successfully")
31 setCurrentItem("")
32 return queryClient.invalidateQueries("todos")
33 },
34 }
35 )
36
37 const _handleAddItem = async (item: string) => {
38 addTodoMutation({ item, user: user })
39 }
40
41 return (
42 <Input
43 type={"text"}
44 value={currentItem}
45 placeholder="Enter item here"
46 onChange={e => setCurrentItem(e.target.value)}
47 onKeyPress={e => {
48 if (e.key === "Enter") {
49 _handleAddItem(currentItem)
50 }
51 }}
52 />
53 )
54}
55
56export default AddTodo

Here, we have an input for user to add the todo item. When user press enter, we invoke _handleAddItem function that handles the data insert.

Since we are using react-query for queries and mutations. we will use useMutation to handle function to insert data.

1const { mutate: addTodoMutation } = useMutation(
2 async (payload: { item: string, user: User }) => {
3 const { data, error } = await supabaseClient.from("todos").insert([
4 {
5 name: payload.item,
6 user_id: payload.user.id,
7 },
8 ])
9
10 if (error) {
11 console.log("Error", error)
12 }
13 return data
14 },
15 {
16 onSuccess: () => {
17 toast.success("Item Added successfully")
18 setCurrentItem("")
19 return queryClient.invalidateQueries("todos")
20 },
21 }
22)

One important thing to note here is invalidateQueries . it’s an important feature of react query to invalidate the todo list whenever a new item is added.

1return queryClient.invalidateQueries("todos")

When it is invalid, react-query fetches the data again for the tag todos and update the list. we will discuss it when we implement the GET request using supabase.

Reading Data

To implement todo list, create Todos/index.tsx and add the following code,

1import { useState } from "react";
2import {
3 QueryClient,
4 useMutation,
5 useQuery,
6 useQueryClient,
7} from "react-query";
8import { supabaseClient } from "@lib/supabaseClient";
9import {
10 Spinner,
11 Stack,
12 ListItem,
13 OrderedList,
14 ListIcon,
15 HStack,
16 VStack,
17 Flex,
18 Text,
19} from "@chakra-ui/react";
20import { DeleteIcon, EditIcon } from "@chakra-ui/icons";
21import toast from "react-hot-toast";
22
23const Todos = () => {
24 const [isOpen, setIsOpen] = useState<boolean>(false);
25 const [selectedItem, setSelectedItem] = useState<{
26 id: number;
27 name: string;
28 }>();
29
30 const queryClient = useQueryClient();
31
32 const { data, isLoading, isError, isSuccess } = useQuery(
33 "todos",
34 async () => {
35 const { data, error } = await supabaseClient
36 .from("todos")
37 .select()
38 .order("id", { ascending: true });
39
40 if (error) {
41 throw new Error(error.message);
42 }
43
44 return data;
45 }
46 );
47
48 if (isLoading) {
49 return (
50 <Stack>
51 <Spinner size="xl" />
52 </Stack>
53 );
54 }
55
56 return (
57 <>
58 <VStack>
59 {data!.map((todo: any) => (
60 <HStack key={todo.id} spacing="24px">
61 <Flex p={6} w="300px" h="50px" justifyContent="space-between">
62 <Text>{todo.name}</Text>
63
64 <Flex w="10px">
65 <DeleteIcon
66 cursor={"pointer"}
67 color="red.500"
68 mr="2"
69 onClick={() => {}}
70 />
71 <EditIcon
72 cursor={"pointer"}
73 onClick={() => {}}
74 />
75 </Flex>
76 </Flex>
77 </HStack>
78 ))}
79 </VStack>
80 </>
81 );
82};
83
84export default Todos;

To fetch the data from database, we implement useQuery hook from react-query with a function to fetch the data from supabase.

1const { data, isLoading, isError, isSuccess } = useQuery("todos", async () => {
2 const { data, error } = await supabaseClient
3 .from("todos")
4 .select()
5 .order("id", { ascending: true })
6
7 if (error) {
8 throw new Error(error.message)
9 }
10
11 return data
12})

Important thing to note here is the supabaseClient.from. It specifies the table that we need to fetch the data from and we can use select to specify the column that we need to fetch.

Editing Data

To edit the data in the list, user clicks on the edit icon and update the item. To achieve that behaviour, we will have a modal that shows the selected item and user can update it.

Create Todos/UpdateModal.tsx and add the following code,

1import { useState, useEffect } from "react"
2import { useMutation, useQueryClient } from "react-query"
3import {
4 Modal,
5 ModalOverlay,
6 ModalContent,
7 ModalBody,
8 ModalCloseButton,
9 ModalHeader,
10 useDisclosure,
11 Input,
12} from "@chakra-ui/react"
13import toast from "react-hot-toast"
14
15//Internal dependencies
16import { supabaseClient } from "@lib/supabaseClient"
17
18type Prop = {
19 todo: {
20 id: number,
21 name: string,
22 },
23 isOpen: boolean,
24 onClose: () => void,
25}
26
27const UpdateTodoModal = ({ todo, isOpen, onClose }: Prop) => {
28 const [currentItem, setCurrentItem] = useState(todo?.name)
29
30 useEffect(() => {
31 if (todo) {
32 setCurrentItem(todo.name)
33 }
34 }, [todo])
35
36 const queryClient = useQueryClient()
37
38 const { mutate } = useMutation(
39 async (item: any) => {
40 const { data, error } = await supabaseClient
41 .from("todos")
42 .update({ name: item.name })
43 .match({ id: item.id })
44
45 if (error) {
46 toast.error("Something went wrong")
47 return error
48 }
49
50 return data
51 },
52 {
53 onSuccess: () => {
54 toast.success("Item Updated successfully")
55 setCurrentItem("")
56 onClose()
57 return queryClient.refetchQueries("todos")
58 },
59 }
60 )
61
62 const _handleUpdate = (item: string) => {
63 mutate({ id: todo.id, name: item })
64 }
65
66 return (
67 <Modal isOpen={isOpen} onClose={onClose}>
68 <ModalOverlay />
69 <ModalContent>
70 <ModalHeader>Update Item</ModalHeader>
71 <ModalCloseButton />
72 <ModalBody pb={6} pt={6}>
73 <Input
74 type={"text"}
75 value={currentItem}
76 placeholder="Enter item here"
77 onChange={e => setCurrentItem(e.target.value)}
78 onKeyPress={e => {
79 if (e.key === "Enter") {
80 _handleUpdate(currentItem)
81 }
82 }}
83 />
84 </ModalBody>
85 </ModalContent>
86 </Modal>
87 )
88}
89
90export default UpdateTodoModal

This is similar to insert query in supabase except using update function with match by id.

1const { data, error } = await supabaseClient
2 .from("todos")
3 .update({ name: item.name })
4 .match({ id: item.id })

Deleting data

To delete an item from the list, add mutation query inside Todos/index.tsx with delete functionality.

1const { mutate } = useMutation(
2 async (id: number) => {
3 const { data, error } = await supabaseClient
4 .from("todos")
5 .delete()
6 .match({ id })
7
8 if (error) {
9 toast.error("Something went wrong")
10 return error
11 }
12
13 return data
14 },
15 {
16 onSuccess: () => {
17 toast.success("Item Deleted successfully")
18 return queryClient.refetchQueries("todos")
19 },
20 }
21)

Then, update DeleteIcon onClick functionality with the delete function,

1const onDeleteItemClick = async (id: number) => {
2 await mutate(id)
3}
4
5return (
6 <DeleteIcon
7 cursor={"pointer"}
8 color="red.500"
9 mr="2"
10 onClick={() => onDeleteItemClick(todo.id)}
11 />
12)

complete code for Todos/index.tsx will be,

1import { useState } from "react";
2import {
3 QueryClient,
4 useMutation,
5 useQuery,
6 useQueryClient,
7} from "react-query";
8import { supabaseClient } from "@lib/supabaseClient";
9import {
10 Spinner,
11 Stack,
12 ListItem,
13 OrderedList,
14 ListIcon,
15 HStack,
16 VStack,
17 Flex,
18 Text,
19} from "@chakra-ui/react";
20import { DeleteIcon, EditIcon } from "@chakra-ui/icons";
21import toast from "react-hot-toast";
22
23import UpdateTodoModal from "./UpdateModal";
24
25const Todos = () => {
26 const [isOpen, setIsOpen] = useState<boolean>(false);
27 const [selectedItem, setSelectedItem] = useState<{
28 id: number;
29 name: string;
30 }>();
31
32 const queryClient = useQueryClient();
33
34 const { data, isLoading, isError, isSuccess } = useQuery(
35 "todos",
36 async () => {
37 const { data, error } = await supabaseClient
38 .from("todos")
39 .select()
40 .order("id", { ascending: true });
41
42 if (error) {
43 throw new Error(error.message);
44 }
45
46 return data;
47 }
48 );
49
50 const { mutate } = useMutation(
51 async (id: number) => {
52 const { data, error } = await supabaseClient
53 .from("todos")
54 .delete()
55 .match({ id });
56
57 if (error) {
58 toast.error("Something went wrong");
59 return error;
60 }
61
62 return data;
63 },
64 {
65 onSuccess: () => {
66 toast.success("Item Deleted successfully");
67 return queryClient.refetchQueries("todos");
68 },
69 }
70 );
71
72 if (isLoading) {
73 return (
74 <Stack>
75 <Spinner size="xl" />
76 </Stack>
77 );
78 }
79
80 const handleUpdateModalClose = () => {
81 setIsOpen(!isOpen);
82 };
83
84 const onItemClick = (todo: { id: number; name: string }) => {
85 setSelectedItem(todo);
86 setIsOpen(true);
87 };
88
89 const onDeleteItemClick = async (id: number) => {
90 await mutate(id);
91 };
92
93 return (
94 <>
95 <VStack>
96 {data!.map((todo: any) => (
97 <HStack key={todo.id} spacing="24px">
98 <Flex p={6} w="300px" h="50px" justifyContent="space-between">
99 <Text>{todo.name}</Text>
100
101 <Flex w="10px">
102 <DeleteIcon
103 cursor={"pointer"}
104 color="red.500"
105 mr="2"
106 onClick={() => onDeleteItemClick(todo.id)}
107 />
108 <EditIcon
109 cursor={"pointer"}
110 onClick={() => onItemClick(todo)}
111 />
112 </Flex>
113 </Flex>
114 </HStack>
115 ))}
116 </VStack>
117 <UpdateTodoModal
118 todo={selectedItem!}
119 isOpen={isOpen}
120 onClose={handleUpdateModalClose}
121 />
122 </>
123 );
124};
125
126export default Todos;

Complete source code is available here

To Read More

Modern React Redux Toolkit - Login ...

User Authentication is one of the common workflow in web applications. In this t...

Building Nodejs Microservice - A Cl...

This Article explains everything about how to build Nodejs Microservices in clou...

I Accidentally wiped the entire dat...

One of the tragic accident in my job turned out to be good learning for me in re...

Never miss a story from us, subscribe to our newsletter