The magic of react-query and supabase
Ankit Jena
Posted on June 28, 2021
It's been a while since I wrote my last article on state management in React using Context. Here's the link for anyone who wants to give it a read. And using custom hooks is still the primary way for me for state management and I have been recommending that to people as well.
In the previous post I had mentioned about UI state(theme, ux state) vs Server state(fetched data). I want to follow up on the sequel article I had promised. Let's get into it.
What we are going to build
Let's not make yet another todo list. I think having some real world data will help understand things better. For this part we are going to make an app, where you can search movies from the TMDB api, add it to your profile as recommendation.
What we are going to use
- NextJS - I, by default use NextJS for any react application I build nowadays over CRA.
- react-query - Data fetching/caching tool, going to help us with our "global/server state problems"
- supabase - Supabase is something I have fallen in love with. It is an open source alternative to firebase(auth, database, storage) but the best part is it's Postgres. This will serve entirely as our backend. You will see how.
- tailwindcss - For styling our app.
Gotta say, all of these have the best developer experience you could ask for.
Let's get started.
Setting up the client
First we need to create the next app and setup tailwind in it.
Setting up the backend(supabase)
Login into supabase and create a project. By default supabase provides you with auth. In this tutorial I won't be going all out on auth(will just do the login). After you create databases, all of them are accessible through the supabase client using an anon key that you get when you create a project. This is also where the best part of their auth architecture comes into place. All of the data by default are accessible to anyone using the anon key. But you can use row level policies on each table to achieve role/auth based authorization.
Let's first create a few tables using the inbuilt SQL editor in the dashboard, based on what we are trying to build.
CREATE TABLE users (
id uuid references auth.users PRIMARY KEY,
name text,
username text unique
);
CREATE TABLE movies (
movie_id integer PRIMARY KEY,
title text,
poster_path text,
overview text,
release_date date
);
CREATE TABLE recommendations (
id uuid NOT NULL DEFAULT extensions.uuid_generate_v4(),
primary key(id),
user_id uuid,
constraint user_id foreign key(user_id) references users(id),
movie_id integer,
constraint movie_id foreign key(movie_id) references movies(movie_id)
);
CREATE UNIQUE INDEX "user_id_movie_id" on recommendations using BTREE ("movie_id", "user_id");
You can create all the tables and relationships using the UI too if you want but you have both the options.
After running this the tables will be created for you. Let's see how our schema looks like using this schema visualizer.
Initializing the client
Let's install the client.
yarn add @supabase/supabase-js
Create a file called app/supabase.ts and initialize the client.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_ANON_KEY);
export default supabase;
Make sure you copy over the project URL and anon key from your dashboard and paste it in .env.local
file.
Before we go further let's setup react-query
as well.
React Query is often described as the missing data-fetching library for React, but in more technical terms, it makes fetching, caching, synchronizing and updating server state in your React applications a breeze.
Setting up React Query
Install the package using
yarn add react-query
and add the following to your _app.js
.
...
imports
...
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0
}
}
})
function MyApp({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
export default MyApp
React query has default retry of 3 times for queries, you can set your custom ones. We have set it to 0. We are also using the devtools which is an awesome tool and helps us view queries and states easily.
Let's clarify a few things before going into this, react-query
is data fetching and tool you can use anyway you like. A few people confuse this with Apollo Client, but Apollo Client is for GraphQL. React Query agnostic to what you are using to fetch data and just deals with promises. Which means you can deal with REST, GraphQL API, file system request as long as a promise is returned.
With React Query, queries are when you are fetching data from the server and mutations when you are changing data on the server.
Signup
In signup we would be using supabase auth to signup and also create a user in the database with additional details.
Create a page in pages/auth/signup.tsx
, for the signup form
import { useRouter } from "next/router"
import { useState } from "react"
import Loader from "../../components/ui/loader"
export default function Signup() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
const [username, setUsername] = useState('')
return (
<div className="min-h-screen grid place-items-center text-xl">
<div className="w-2/3 lg:w-1/3 shadow-lg flex flex-col items-center">
<h1 className="text-4xl font-semibold">Sign up</h1>
<div className="mt-8 w-full lg:w-auto px-4">
<p>Name</p>
<input
type="text"
className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
onChange={e => setName(e.target.value)}
/>
</div>
<div className="mt-8 w-full lg:w-auto px-4">
<p>Email</p>
<input
type="text"
className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
onChange={e => setEmail(e.target.value)}
/>
</div>
<div className="mt-8 w-full lg:w-auto px-4">
<p>Password</p>
<input
className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
type="password"
onChange={e => setPassword(e.target.value)}
/>
</div>
<div className="my-8 w-full lg:w-auto px-4">
<p>Username</p>
<input
type="text"
className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
onChange={e => setUsername(e.target.value)}
/>
</div>
<div className="mb-8 w-1/5">
<button
className="bg-blue-500 text-white px-8 py-2 rounded w-full"
>
<span>Sign up</span>
</button>
</div>
</div>
</div>
)
}
Let's create a custom hook in hooks/useCreateUser.ts
We can always have the fetching/mutating inside the component but having separate custom hooks gives rise to cleaner code.
import { useMutation, useQueryClient } from "react-query"
import supabase from "../app/supabase"
interface User {
name: string;
email: string;
username: string;
password: string;
}
const createUser = async (user: User) => {
// Check if username exists
const { data: userWithUsername } = await supabase
.from('users')
.select('*')
.eq('username', user.username)
.single()
if(userWithUsername) {
throw new Error('User with username exists')
}
const { data, error: signUpError } = await supabase.auth.signUp({
email: user.email,
password: user.password
})
if(signUpError) {
throw signUpError
}
return data
}
export default function useCreateUser(user: User) {
return useMutation(() => createUser(user), {
onSuccess: async(data) => {
const { data: insertData, error: insertError } = await supabase
.from('users')
.insert({
name: user.name,
username: user.username,
id: data.user.id
})
if(insertError) {
throw insertError
}
return insertData
}
})
}
Let's go through the above code.
First we have the method the create the user. In there we first check whether an user with the username exists and if it does we throw an error. So a thing to notice here is that the supabase client by default doesn't throw an error, instead returns it in the return object. Then we use supabase.auth.signUp()
method with email and password. We have disabled, the email verification in supabase auth dashboard for this tutorial. If it succeeds we return the data we get back.
Next we have the default export which uses the useMutation
hook from react query. We pass in the function we created above. Also since we also want to insert a user in our users table, we have onSuccess
side effect in options which gets the data returned by the createUser
method. Here we use supabase.from
to build a insert query and we use the user id returned from the signup success.
Perfect, now we add the logic in pages/auth/signup
...
import useCreateUser from "../../hooks/useCreateUser"
export default function Signup() {
...
const createUserMutation = useCreateUser({
email,
password,
name,
username
})
if(createUserMutation.isSuccess) {
router.push("/")
}
...
{createUserMutation.isError && <p className="text-sm mb-8 text-red-500">{createUserMutation.error.message}</p>}
...
<button
className="bg-blue-500 text-white px-8 py-2 rounded w-full"
onClick={() => createUserMutation.mutate()}
>
{createUserMutation.isLoading?
<span>
<Loader
height={30}
width={30}
/>
</span> :
<span>Sign up</span>
}
</button>
We import the custom hook and define it in our component. We add an onclick action on the button which triggers the mutation. We also use the isLoading
, isError
, error
for displaying. We use the isSuccess
to route the user to the home page.
Now on entering the details and clicking signup a user should be created, and you should be redirected to the signup page.
Login
Let's quickly add the login page as well.
Let's create a new page at auth/login
route and add some simple ui.
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
return (
<div className="min-h-screen grid place-items-center text-xl">
<div className="w-2/3 lg:w-1/3 shadow-lg flex flex-col items-center">
<div className="mt-8 w-full lg:w-auto px-4">
<p>Email</p>
<input
type="text"
onChange={e => setEmail(e.target.value)}
className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
/>
</div>
<div className="my-8 w-full lg:w-auto px-4">
<p>Password</p>
<input
className="h-8 focus:outline-none shadow-sm border p-4 rounded mt-2 w-full lg:w-auto"
type="password"
onChange={e => setPassword(e.target.value)}
/>
</div>
<div className="mb-8">
<button className="bg-blue-500 text-white px-8 py-2 rounded">Login</button>
</div>
</div>
</div>
)
}
Create a similar hook called hooks/useLogin.ts
import { useMutation } from 'react-query'
import supabase from '../app/supabase'
const login = async ({email, password}) => {
const { data, error } = await supabase.auth.signIn({
email,
password
})
if(error) {
throw new Error(error.message)
}
return data
}
export default function useLogin({ email, password }) {
return useMutation('login', () => login({email, password}))
}
And similarly in pages/auth/login.tsx
...
const loginMutation = useLogin({email, password})
if(loginMutation.isSuccess) {
router.push('/')
}
...
...
{loginMutation.isError && <p className="text-sm mb-8 text-red-500">{loginMutation.error.message}</p>}
...
<button
className="bg-blue-500 text-white px-8 py-2 rounded w-full"
onClick={() => loginMutation.mutate()}
>
{loginMutation.isLoading?
<span>
<Loader
height={30}
width={30}
/>
</span> :
<span>Login</span>
}
</button>
It's pretty similar to signup, we call the supabase.auth.signIn
method and redirect the user if the mutation is successful.
Now if you enter your credentials, login should work.
Authenticated Pages
Now when the user logs in we want to fetch the user details, name and username in our case which will be available to the entire app. Let's create a hook for that.
Create a file in hooks/useUser.ts
import { useQuery } from 'react-query'
import supabase from '../app/supabase'
const getUser = async ({userId}) => {
const { data, error } = await supabase
.from('users')
.select()
.eq('id', userId)
.single()
if(error) {
throw new Error(error.message)
}
if(!data) {
throw new Error("User not found")
}
return data
}
export default function useUser() {
const user = supabase.auth.user()
return useQuery('user', () => getUser(user?.id))
}
The useQuery hook needs a unique key as the first parameter. > At its core, React Query manages query caching for you based on query keys. Query keys can be as simple as a string, or as complex as an array of many strings and nested objects. As long as the query key is serializable, and unique to the query's data, you can use it! Read more here.
We define a getUser
method which uses the supabase client query builder. This is equivalent to
SELECT * FROM users where id = <userId>
In the default export, we use the supabase.auth.user()
method which returns the user if session exists. Note the user?id
in the getUser
method call, this is because the auth.user
method can initially return null and eventually resolves to a value.
Now we want to make our home page authenticated. So when a user doesn't have a session, he will be redirected to the login page.
To do that let's create a file in components/Protected.tsx
import Loader from "./ui/loader"
import { useRouter } from 'next/router'
import useUser from "../hooks/useUser"
export default function ProtectedWrapper({children}) {
const router = useRouter()
const { isLoading, isError } = useUser()
if(isLoading) {
return (
<div className="h-screen grid place-items-center">
<Loader height={200} width={200}/>
</div>
)
}
if(isError) {
router.push('/auth/login')
return (
<div className="h-screen grid place-items-center">
<Loader height={200} width={200}/>
</div>
)
}
return (
<div>
{children}
</div>
)
}
This is a wrapper component which basically checks for the session and redirects if it's not there. Let's see how it happens. So we are using the useUser
we defined earlier and destructuring isLoading
and isError
from the it. If it's loading, we display a loader and if the query errors we redirect the user.
The isLoading
state happens when the query is being fetched for the first time, likely during component mount for the first time/window reload.
The isError
state is when the useUser
query errors. This is the beauty of react query. If the session doesn't exist, the supabase.auth.user()
will never resolve to a value and the getUser
call will throw an error.
Also when the value returned from supabase.auth.user
changes from null
to user, the query is automatically refetched.
Now let's use this ProtectedWrapper
inside our index page.
...
import ProtectedWrapper from "../components/Protected"
export default function Home() {
return (
<ProtectedWrapper>
...
</ProtectedWrapper>
)
}
Let's see it in action.
This one is when there is no session.
This one is where browser session exists.
Awesome, we can now use this wrapper in pages which we want to be authenticated.
Displaying the user
Let's create a Navbar component
import Link from 'next/link'
import Loader from "../ui/loader";
import { useRouter } from "next/router";
export default function Navbar() {
return (
<div className="flex items-center justify-around py-6 bg-blue-500 text-white shadow">
<Link href="/">
<div className="text-2xl">
Home
</div>
</Link>
<div className="text-xl flex items-center space-x-4">
<div>
<Link href="/search ">
Search
</Link>
</div>
<div>
Username
</div>
<div
className="cursor-pointer"
>
{/* Logout feather icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="feather feather-log-out"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line>
</svg>
</div>
</div>
</div>
)
}
Now let's say we want to display the username in our Navbar, we don't have to do anything but reuse the useUser
query again in the Navbar component. React query by default caches all queries for 5 mins(can be changed), after which the query is refetched. Here's how.
...
import useUser from "../../hooks/useUser"
export default function Navbar() {
const { data, isLoading } = useUser({userId: user?.id})
...
<div>
{isLoading ?
<span>
<Loader
height={30}
width={30}
/>
</span>
: data?.username}
</div>
...
A few things that react-query
takes care for us here
- We didn't have to add any logic to share the state, we can just use the data from the hook
- We get, the state object in navbar as well which we use to display a loading indication incase the user is being fetched
No declaring of many initial states, and dispatching of actions. :3
Log out
Let's also add the log out logic in the navbar. You know the script, create a hook and use the hook.
import { useMutation, useQueryClient } from "react-query"
import supabase from "../app/supabase"
const logout = async () => {
const { error } = await supabase.auth.signOut()
if(error) {
throw error
}
}
export default function useLogOut() {
const queryClient = useQueryClient()
return useMutation(() => logout(), {
onSuccess: () => {
queryClient.removeQueries()
}
})
}
We use the supabase.auth.signOut
which destroys the session and logs the user out.
A thing to notice here is since our app uses queries to display data and not any kind of store, we need to remove the queries once a user logs out. To do that we use the queryClient from the useQueryClient
hook and on the success side effect we remove all the queries using queryClient.removeQueries
method.
...
import useLogOut from "../../hooks/useLogOut";
import { useRouter } from "next/router";
...
export default function Navbar() {
const logoutMutation = useLogOut()
const router = useRouter()
if(logoutMutation.isSuccess) {
router.push('/auth/login')
}
...
<div
className="cursor-pointer"
onClick={() => logoutMutation.mutate()}
>
<svg
...
</svg>
</div>
Done, clicking the logout button now destroys the session and redirects to the login page.
Searching for movies
We know the pattern now, let's create a hook for searching movies.
Create a file in hooks/useMovies.ts
import { useQuery } from 'react-query'
const searchMovies = async (query) => {
const response = await fetch(`https://api.themoviedb.org/3/search/movie?api_key=${process.env.NEXT_PUBLIC_TMDB_API_KEY}&query=${query}&language=en-US&page=1`)
if(!response.ok) {
throw new Error('Error searching movies')
}
return response.json()
}
export default function useMovies({ query }) {
return useQuery('movies', () => searchMovies(query), {
enabled: false
})
}
The enabled: false
here means the query doesn't run automatically and has to be manually triggered using refetch
. More here
Create a page called search.tsx
import Navbar from "../components/layouts/navbar"
import Search from "../components/search"
import ProtectedWrapper from "../components/Protected"
export default function Home() {
return (
<ProtectedWrapper>
<div className="min-h-screen">
<Navbar />
<div className="container mx-auto">
<Search />
</div>
</div>
</ProtectedWrapper>
)
}
And the Search component in components/search/index.tsx
import { useState } from 'react'
import useMovies from '../../hooks/useMovies'
import SearchResultItem from './SearchResultItem'
import Loader from '../ui/loader'
export default function Search() {
const [query, setQuery] = useState('')
const { refetch, isFetching, data, isSuccess, isIdle } = useMovies({query})
return (
<div className="mt-20 text-xl flex flex-col items-center">
<div className="flex">
<input
className="border shadow px-8 py-2 rounded focus:outline-none"
onChange={e => setQuery(e.target.value)}
/>
<button
className="bg-blue-500 py-2 px-4 shadow rounded text-white w-32"
onClick={() => refetch()}
>
{
isFetching ?
<span>
<Loader
height={30}
width={30}
/>
</span>:
`Search`
}
</button>
</div>
<div className="mt-10">
{isSuccess &&
<div className="grid place-items-center">
{data
?.results
.sort((a, b) => b.popularity - a.popularity)
.map(
(item, index) =>
<SearchResultItem
title={item.title}
overview={item.overview}
key={index}
poster_path={item.poster_path}
release_date={item.release_date}
/>
)
}
</div>
}
</div>
{isSuccess
&& !data?.results.length
&&
<div className="mt-10">
<p>No results found</p>
</div>
}
{isIdle && <div className="mt-10">Search for a movie</div>}
</div>
)
}
And the search item component
import dayjs from 'dayjs'
export default function SearchResultItem({title, overview, poster_path, release_date}) {
return (
<div className="flex w-2/3 mt-4 shadow rounded py-2">
<div className="h-30 w-1/4 grid place-items-center flex-none">
<img src={`https://www.themoviedb.org/t/p/w94_and_h141_bestv2${poster_path}`} alt="poster" height="150" width="150" />
</div>
<div className="px-4 flex flex-col justify-around">
<p className="text-2xl">{title}</p>
<p className="text-base">{overview.slice(0, 200)}...</p>
<p className="text-base">{dayjs(release_date).format('YYYY')}</p>
<button className="w-20 px-6 py-2 text-base bg-blue-500 text-white rounded">Add</button>
</div>
</div>
)
}
Now we can search for a movie and are displaying it in a list. One thing you will notice that even if you change pages and come back to the search page, the movie results if you had searched would have been cached and are shown. Woohoo.
Adding a movie to your recommendation
Let's create another hook for that.
In a file hooks/useAddMovie.ts
import { useMutation } from "react-query"
import supabase from "../app/supabase"
interface Movie {
movie_id: number;
title: string;
overview: string;
poster_path: string;
release_date: string;
}
const addMovie = async (movie: Movie, user_id: string) => {
const { error } = await supabase
.from('movies')
.upsert(movie)
.single()
if(error) {
throw error
}
const { data, error: err } = await supabase
.from('recommendations')
.upsert({movie_id: movie.movie_id, user_id}, {
onConflict: 'user_id, movie_id'
})
.single()
if(err) {
throw err
}
return data
}
export default function useAddMovie(movie: Movie) {
const user = supabase.auth.user()
return useMutation(() => addMovie(movie, user?.id))
}
Note that we are using upsert
in both the calls, one to save the movie details so a duplicate movie isn't added and second to prevent a duplicate entry in recommendation(we have the onConflict
clause to satisfy the unique index constraint). Also we are using supabase.auth.user()
to pass in the user id, for the second method.
Then in components/search/SearchResultItem.tsx
...
imports
...
export default function SearchResultItem({id, title, overview, poster_path, release_date}) {
const addMovie = useAddMovie({
movie_id: id,
title,
overview,
poster_path,
release_date
})
...
<button
className="w-32 px-6 py-2 text-base bg-blue-500 text-white rounded"
onClick={() => addMovie.mutate()}
>
{addMovie.isLoading ?
<span>
<Loader
height={25}
width={25}
/>
</span>:
`Add`}
</button>
...
Awesome now we can add a movie to our list. The last thing remaining is to display them in the home screen.
Displaying your recommendations
Create a file in hooks/useRecommendations.ts
import { useQuery } from 'react-query'
import supabase from '../app/supabase'
const fetchRecommendations = async (user_id) => {
const { data, error } = await supabase
.from('recommendation')
.select(`
movie (
*
)
`)
.eq('user_id', user_id)
if(error) {
throw new Error(error.message)
}
return data
}
export default function useRecommendations() {
const user = supabase.auth.user()
return useQuery('recommendations', () => fetchRecommendations(user?.id))
}
Here we are fetching from the foreign table movie using the movie id foreign key and matching by the user id.
Let's update our components/recommendations/index.tsx
import Link from 'next/link'
import useRecommendations from '../../hooks/useRecommendations'
import MovieCard from './MovieCard'
import Loader from '../ui/loader'
export default function Recommendations() {
const { data, isSuccess, isLoading } = useRecommendations()
if(isLoading) {
return (
<div className="h-screen grid place-items-center">
<Loader height={200} width={200} />
</div>
)
}
return (
<div>
<h2 className="text-3xl my-4">Your recommendations</h2>
<hr />
{isSuccess && !data.length && <div className="mt-20 text-xl grid place-items-center">
<p>You have no recommendations yet.</p>
<p>
<span className="cursor-pointer text-blue-500"><Link href="/search">Search</Link></span>
<span>{` `}for movies and add them to your recommendations.</span>
</p>
</div>}
{
isSuccess &&
<div className="grid grid-cols-3 gap-x-4 gap-y-4">
{data.map(({movie: {
movie_id,
title,
overview,
poster_path,
release_date
} }) => (
<MovieCard
key={movie_id}
title={title}
poster_path={poster_path}
/>
))}
</div>
}
</div>
)
}
And components/recommendations/MovieCard.tsx
export default function MovieCard({title, poster_path}) {
return (
<div className="grid place-items-center shadow rounded py-4">
<img src={`https://www.themoviedb.org/t/p/w300_and_h450_bestv2${poster_path}`} />
<p className="mt-4 text-2xl font-semibold">{title}</p>
</div>
)
}
Perfect, now when we load the home page we have a loader when the query is fetched. If you go into search and add a movie, you will see the home page will have fetched that automatically. That's because when move to a different page, the recommendations query becomes inactive and is automatically fetched again on component mount. If you open devtools you will also notice that the useUser
query is also being fetched multiple times(when we go to a new page)
Stale queries are refetched automatically in the background when:
New instances of the query mount
The window is refocused
The network is reconnected.
The query is optionally configured with a refetch interval.
This behaviour is good but sometimes undesirable. Gladly we can configure it in query default options.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 0,
refetchOnMount: false,
refetchOnWindowFocus: false
}
}
})
We can also add this individually to a query. Now that we have disabled auto fetch on remount, we want to refetch the query when we add a movie from search page.
For this we can again use the queryClient
from the useQueryClient
hook. Here we want to use the refetchQueries
method. If the query is currently being used in the same page, you can use invalidateQueries
method which makes the stale and are refetched automatically. Since our use case is for a different page we will use refetchQueries
instead.
In our hooks/useAddMovie.ts
file
...
export default function useAddMovie(movie: Movie) {
const queryClient = useQueryClient()
const user = supabase.auth.user()
return useMutation(() => addMovie(movie, user?.id), {
onSuccess: () => {
queryClient.refetchQueries('recommendations')
}
})
}
Now when you add a movie, the query is refetched automatically.
React query has so many features, it's impossible to cover them all in a go. You can play around with react-query
with an application, even better if you refactor an existing one to react-query
.
The code until this point is on github
That's it for this part. In the next part we will build upon this app and add lists, which you can create and add your recommendations into and more features. We will delve more into supabase (row level policies etc), and more react query features.
Thanks for reading up to this point. If you have any questions or doubts feel free to ask them in the comments. If you liked the post like and share it on twitter.
Documentation links
- NextJS - https://nextjs.org/docs/getting-started
- React Query - https://react-query.tanstack.com/overview
- Supabase Auth - https://supabase.io/docs/guides/auth
- Supabase Client - https://supabase.io/docs/reference/javascript/supabase-client
- Tailwind CSS - https://tailwindcss.com/docs
Posted on June 28, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.