SERIES: Let's build SupaAuth #3
Re-factor for good!
9 min read • –––
You're on part 3 of the 6-part series Let's build SupaAuth where You'll build an authentication system using Supabase and Next.js
Re-factor for Good
By the 2nd article in this series, we have set up a Next.js + Supabase codebase and introduced functions to let our users sign-up and sign in. The natural next step should be to create protected pages that will only be available to authenticated users. But, we'll take a slight detour to refactor our code a bit. Good solutions don't just work right, they scale well too!
All the authentication code is currently co-located with the /index
page, even when authentication-related information and functions have app-wide utility. Any other page or component needing the user info or functions will have no way to access it without a few re-arrangements.
Lift the Authentication concerns up!
Functionalities like registrations, login, etc. make up for a part of an application that needs to be globally accessible. When we think of these kinds of use case in React, React Context comes as a recommended approach
- to share and consume a global state
- to share functions that can act on the global state
You can share the authentication details like a user's detail and login information, as well as functions like signUp
, signIn
through special context methods called (Context).Providers
, and can grab whatever you need independent of where your component exists in the React's component tree through (Context).Consumer
methods - render props or hooks
React's Context will be just the right fit for placing the auth concerns. It can simply be created as
import { createContext } from 'react'
const AuthContext = createContext(null)
export default AuthContext
Let's create a dedicated library folder auth
for all the authentication related functions inside the lib
directory. Once created, create a AuthContext.tsx
inside /src/lib/auth
with the following code
import { createContext, FunctionComponent } from 'react'
export type AuthContextProps = {/* to be defined */}
export const AuthContext = createContext<Partial<AuthContextProps>>({})
export const AuthProvider: FunctionComponent = ({
children,
}) => {
return (<AuthContext.Provider value={{
/* to be populated */
}}>
{children}
</AuthContext.Provider>
)
}
You now have the Provider
shell ready. AuthContextProps
defines the contract for what could be shared. Partial
tells that it would be possible to initialize the context with an empty {}
, even when AuthContextProps
needs some mandatory values. Exporting AuthContext.Provider
helps us take one more decision away from the code that'll consume the context, minimally exposing what's essential.
Pull-in the common concern
With the above structure in place, let's pull in the signUp
and signIn
methods and all of their dependency functions, types within this context.
import { createContext, FunctionComponent, useState } from 'react'
import { supabase } from '~/lib/supabase' // dependency for signIn, signUp
import { useMessage } from '~/lib/message' // dependency for signIn, signUp
import { SupabaseAuthPayload } from './auth.types' // dependency for signIn, signUp - we'll define it shortly in a separate file
export type AuthContextProps = {/* to be defined shortly */}
export const AuthContext = createContext<Partial<AuthContextProps>>({})
export const AuthProvider: FunctionComponent = ({
children,
}) => {
const [ loading, setLoading ] = useState(false)
const { handleMessage } = useMessage()
const signUp = async (payload: SupabaseAuthPayload) => {
try {
setLoading(true)
const { error } = await supabase.auth.signUp(payload)
if (error) {
handleMessage({ message: error.message, type: 'error' })
}
else {
handleMessage({ message: 'Signup successful. Please check your inbox for a confirmation email!', type: 'success' })
}
} catch (error) {
handleMessage({ message: error.error_description || error, type: 'error' })
} finally {
setLoading(false)
}
}
const signIn = async (payload: SupabaseAuthPayload) => {
try {
setLoading(true)
const { error } = await supabase.auth.signIn(payload)
if (error) {
handleMessage({ message: error.message, type: 'error' })
} else {
handleMessage({ message: 'Log in successful. I\'ll redirect you once I\'m done', type: 'success' })
}
} catch (error) {
handleMessage({ message: error.error_description || error, type: 'error' })
} finally {
setLoading(false)
}
}
return (<AuthContext.Provider value={{
/* to be populated */
}}>
{children}
</AuthContext.Provider>
)
}
We now have both signUp
and signIn
methods within the Context function. Let's zero in on what we'd like to share with the rest of the application next
Share the Common Concerns
If you see it from the perspective of what methods were used so far, it's going to be everything we lifted and pulled in.
signUp
so that we can sign-up from anywhere in the appsignIn
so that we can sign in from anywhere in the app Today homepage/
is using these methods, tomorrow dedicated/sign-up
or/sign-in
pages might use it. It could be even consumed in a pop-up to prevent unnecessary transitions and clicks. Additionally, we'll also pass aloading
state so that consumers can show a progress indicator while the sin up or sign-in process is in-flight.
Let's populate the type AuthContextProps
defining the shape of the value our AuthContext.Provider
will be allowed to share
/* preceeding code (unchanged) */
export type AuthContextProps = {
signUp: (payload: SupabaseAuthPayload) => void,
signIn: (payload: SupabaseAuthPayload) => void,
loading: boolean
}
/* succeeding code (unchanged) */
We'll have to update the Provider object too accordingly
/* preceeding code (unchanged) */
return (<AuthContext.Provider value={{
signUp,
signIn,
loading
}}>
{children}
</AuthContext.Provider>
/* preceeding code (unchanged) */
Let's also define the auth payload's type in a separate src/lib/auth/auth.types.ts
file
export type SupabaseAuthPayload = {
email: string,
password: string
}
With all of the above changes in place our AuthContext.tsx
component should look like
import { createContext, FunctionComponent, useState } from 'react'
import { supabase } from '~/lib/supabase'
import { useMessage } from '~/lib/message'
import { SupabaseAuthPayload } from './auth.types'
export type AuthContextProps = {
signUp: (payload: SupabaseAuthPayload) => void,
signIn: (payload: SupabaseAuthPayload) => void,
loading: boolean
}
export const AuthContext = createContext<Partial<AuthContextProps>>({})
export const AuthProvider: FunctionComponent = ({
children,
}) => {
const [ loading, setLoading ] = useState(false)
const { handleMessage } = useMessage()
const signUp = async (payload: SupabaseAuthPayload) => {
try {
setLoading(true)
const { error } = await supabase.auth.signUp(payload)
if (error) {
handleMessage({ message: error.message, type: 'error' })
}
else {
handleMessage({ message: 'Signup successful. Please check your inbox for a confirmation email!', type: 'success' })
}
} catch (error) {
handleMessage({ message: error.error_description || error, type: 'error' })
} finally {
setLoading(false)
}
}
const signIn = async (payload: SupabaseAuthPayload) => {
try {
setLoading(true)
const { error } = await supabase.auth.signIn(payload)
if (error) {
handleMessage({ message: error.message, type: 'error' })
} else {
handleMessage({ message: 'Log in successful. I\'ll redirect you once I\'m done', type: 'success' })
}
} catch (error) {
handleMessage({ message: error.error_description || error, type: 'error' })
} finally {
setLoading(false)
}
}
return (<AuthContext.Provider value={{
signUp,
signIn,
loading
}}>
{children}
</AuthContext.Provider>
)
}
The AuthContext
is now ready to be wrapped around our Next.js app so that what it contains can be available to the entire app.
Wire the App with AuthProvider
Open /src/pages/_app.tsx
and put the AuthContext.Provider
around Next.js root Component
/* Pre-existing imports (unchanged) */
import { AuthProvider } from '~/lib/auth'
function MyApp({ Component, pageProps }: AppProps) {
/* Pre-existing code (unchanged) */
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
/* Pre-existing code (unchanged) */
}
Once the changes are done, _app.tsx
should look like
import 'tailwindcss/tailwind.css'
import '~/styles/globals.css'
import React from 'react'
import type, { AppProps } from 'next/app'
import Head from 'next/head'
import { DefaultSeo } from 'next-seo';
import SEO from '../../next-seo.config';
import { MessageProvider } from '~/lib/message'
import { AuthProvider } from '~/lib/auth'
function MyApp({ Component, pageProps }: AppProps) {
const pageMeta = (Component as any)?.defaultProps?.meta || {}
const pageSEO = {...SEO, ...pageMeta }
return <React.Fragment>
<Head>
<meta content="width=device-width, initial-scale=1" name="viewport" />
</Head>
<DefaultSeo {...pageSEO } />
<MessageProvider>
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
</MessageProvider>
</React.Fragment>
}
export default MyApp
The beauty of convenience functions
You can use the Auth context now anywhere in the app. All you need is a import from ~/lib/auth/AuthContext.tsx
import { AuthContext } from '~/lib/auth/AuthContext.tsx'
and then a use like
const { signIn, sigUp, loading } = useContext(AuthContext)
but why stop here if we can improve this further? Let's create a re-usable hook to make it a bit more simple to use the AuthContext
.
useAuth Hook
Let's create a file useAuth.tsx
under /src/lib/auth
and place the following code
import { useContext } from 'react'
import { AuthContext } from './AuthContext'
export const useAuth = () => {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error(
'useAuth must be used within an AuthContext.Provider'
)
}
return context
}
The final exports
For making it a bit more convenient for our library users we'll export everything that's publicly usable through an index.ts
file at src/lib/auth
export * from './auth.types'
export * from './AuthContext'
export * from './useAuth'
With this final now we have a well-organized auth
library that can be referred from anywhere in the app with a simple import {...} from '~/lib/auth/'
for the auth types, user objects, and auth functions
Clean-up the Landing Page
Replace all of the un-necessary imports like
import { useMessage } from '~/lib/message'
import { supabase } from '~/lib/supabase'
with one
import { useAuth } from '~/lib/auth'
Replace all of the un-necessary local states
const [loading, setLoading] = useState(false)
const { handleMessage } = useMessage()
with one
const { loading, signIn, signUp } = useAuth()
in the IndexComponent
and see your light and clean page component fly :-).
You can find all of the changes here
Re-factor for good
along with few enhancements in the follow-up PRs
Takeaways and What's Next
Everything we re-arranged and re-factored should be non-breaking changes, and with the auth-related concerns lifted to the top of the component tree, your page-level code should feel lighter and should eventually have just the page-specific code.
These sorts of slight detours are necessary to course-correct our approaches to problem-solving as our applications evolve. It's always better now than late.
With all the heavy lifting done to separate the global and local concerns, we should be able to continue building the rest of SupaAuth's functionality like
- allowing the signed users to view a protected page
- redirection users to the log-in page if they're not logged in
- logging out of the logged-in user
and more in the next few parts.
Thank you for being till the end 🙌 . If you enjoyed this article, or learned something new, please take a second to tweet this article or share on LinkedIn so others can discover it. Or add to the discussion on Twitter.