mirror of
https://github.com/supabase/supabase.git
synced 2026-07-04 11:34:23 +08:00
* Fix Grammar in "Update with-nextjs.mdx" * Fix misnaming of js/ts in "Update with-nextjs.mdx"
1422 lines
36 KiB
Plaintext
1422 lines
36 KiB
Plaintext
---
|
|
title: 'Build a User Management App with Next.js'
|
|
description: 'Learn how to use Supabase in your Next.js App.'
|
|
---
|
|
|
|
<QuickstartIntro />
|
|
|
|

|
|
|
|
<Admonition type="note">
|
|
|
|
If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-user-management).
|
|
|
|
</Admonition>
|
|
|
|
<ProjectSetup />
|
|
|
|
## Building the app
|
|
|
|
Let's start building the Next.js app from scratch.
|
|
|
|
### Initialize a Next.js app
|
|
|
|
We can use [`create-next-app`](https://nextjs.org/docs/getting-started) to initialize an app called `supabase-nextjs`:
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
```bash
|
|
npx create-next-app@latest --use-npm supabase-nextjs
|
|
cd supabase-nextjs
|
|
```
|
|
|
|
</TabPanel>
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
```bash
|
|
npx create-next-app@latest --ts --use-npm supabase-nextjs
|
|
cd supabase-nextjs
|
|
```
|
|
|
|
</TabPanel>
|
|
</Tabs>
|
|
|
|
Then install the Supabase client library: [supabase-js](https://github.com/supabase/supabase-js)
|
|
|
|
```bash
|
|
npm install @supabase/supabase-js
|
|
```
|
|
|
|
And finally we want to save the environment variables in a `.env.local`.
|
|
Create a `.env.local` file at the root of the project, and paste the API URL and the `anon` key that you copied [earlier](#get-the-api-keys).
|
|
|
|
```bash .env.local
|
|
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
|
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
|
|
```
|
|
|
|
### App styling (optional)
|
|
|
|
An optional step is to update the CSS file `app/globals.css` to make the app look nice.
|
|
You can find the full contents of this file [here](https://raw.githubusercontent.com/supabase/supabase/master/examples/user-management/nextjs-user-management/app/globals.css).
|
|
|
|
### Supabase Server-Side Auth
|
|
|
|
Next.js is a highly versatile framework offering pre-rendering at build time (SSG), server-side rendering at request time (SSR), API routes, and middleware edge-functions.
|
|
|
|
To better integrate with the framework, we've created the `@supabase/ssr` package for Server-Side Auth. It has all the functionalities to quickly configure your Supabase project to use cookies for storing user sessions. See the [Next.js Server-Side Auth guide](/docs/guides/auth/server-side/nextjs) for more information.
|
|
|
|
Install the package for Next.js.
|
|
|
|
```bash
|
|
npm install @supabase/ssr
|
|
```
|
|
|
|
### Supabase utilities
|
|
|
|
There are two different types of clients in Supabase:
|
|
|
|
1. **Client Component client** - To access Supabase from Client Components, which run in the browser.
|
|
2. **Server Component client** - To access Supabase from Server Components, Server Actions, and Route Handlers, which run only on the server.
|
|
|
|
It is recommended to create the following essential utilities files for creating clients, and organize them within `utils/supabase` at the root of the project.
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
Create a `client.js` and a `server.js` with the following functionalities for client-side Supabase and server-side Supabase, respectively.
|
|
|
|
<CH.Code className="min-h-[30rem]">
|
|
|
|
```jsx utils/supabase/client.js
|
|
import { createBrowserClient } from '@supabase/ssr'
|
|
|
|
export function createClient() {
|
|
// Create a supabase client on the browser with project's credentials
|
|
return createBrowserClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
)
|
|
}
|
|
```
|
|
|
|
```jsx utils/supabase/server.js
|
|
import { createServerClient } from '@supabase/ssr'
|
|
import { cookies } from 'next/headers'
|
|
|
|
export function createClient() {
|
|
const cookieStore = cookies()
|
|
|
|
// Create a server's supabase client with newly configured cookie,
|
|
// which could be used to maintain user's session
|
|
return createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return cookieStore.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
try {
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
cookieStore.set(name, value, options)
|
|
)
|
|
} catch {
|
|
// The `setAll` method was called from a Server Component.
|
|
// This can be ignored if you have middleware refreshing
|
|
// user sessions.
|
|
}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
}
|
|
```
|
|
|
|
</CH.Code>
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
<CH.Code className="min-h-[30rem]">
|
|
|
|
```tsx utils/supabase/client.ts
|
|
import { createBrowserClient } from '@supabase/ssr'
|
|
|
|
export function createClient() {
|
|
// Create a supabase client on the browser with project's credentials
|
|
return createBrowserClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
|
)
|
|
}
|
|
```
|
|
|
|
```tsx utils/supabase/server.ts
|
|
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
|
import { cookies } from 'next/headers'
|
|
|
|
export function createClient() {
|
|
const cookieStore = cookies()
|
|
|
|
// Create a server's supabase client with newly configured cookie,
|
|
// which could be used to maintain user's session
|
|
return createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return cookieStore.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
try {
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
cookieStore.set(name, value, options)
|
|
)
|
|
} catch {
|
|
// The `setAll` method was called from a Server Component.
|
|
// This can be ignored if you have middleware refreshing
|
|
// user sessions.
|
|
}
|
|
},
|
|
},
|
|
}
|
|
)
|
|
}
|
|
```
|
|
|
|
</CH.Code>
|
|
|
|
</TabPanel>
|
|
|
|
</Tabs>
|
|
|
|
### Next.js middleware
|
|
|
|
Since Server Components can't write cookies, you need middleware to refresh expired Auth tokens and store them. This is accomplished by:
|
|
|
|
- Refreshing the Auth token with the call to `supabase.auth.getUser`.
|
|
- Passing the refreshed Auth token to Server Components through `request.cookies.set`, so they don't attempt to refresh the same token themselves.
|
|
- Passing the refreshed Auth token to the browser, so it replaces the old token. This is done with `response.cookies.set`.
|
|
|
|
You could also add a matcher, so that the middleware only runs on route that access Supabase. For more information, check out this [documentation](https://nextjs.org/docs/app/building-your-application/routing/middleware#matching-paths).
|
|
|
|
<Admonition type="danger">
|
|
|
|
Be careful when protecting pages. The server gets the user session from the cookies, which can be spoofed by anyone.
|
|
|
|
Always use `supabase.auth.getUser()` to protect pages and user data.
|
|
|
|
_Never_ trust `supabase.auth.getSession()` inside server code such as middleware. It isn't guaranteed to revalidate the Auth token.
|
|
|
|
It's safe to trust `getUser()` because it sends a request to the Supabase Auth server every time to revalidate the Auth token.
|
|
|
|
</Admonition>
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
Create a `middleware.js` file at the project root and another one within the `utils/supabase` folder. The `utils/supabase` file contains the logic for updating the session. This is used by the `middleware.js` file, which is a Next.js convention.
|
|
|
|
<CH.Code className="min-h-[30rem]">
|
|
|
|
```jsx middleware.js
|
|
import { updateSession } from '@/utils/supabase/middleware'
|
|
|
|
export async function middleware(request) {
|
|
// update user's auth session
|
|
return await updateSession(request)
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except for the ones starting with:
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization files)
|
|
* - favicon.ico (favicon file)
|
|
* Feel free to modify this pattern to include more paths.
|
|
*/
|
|
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
|
],
|
|
}
|
|
```
|
|
|
|
```jsx utils/supabase/middleware.js
|
|
import { createServerClient } from '@supabase/ssr'
|
|
import { NextResponse } from 'next/server'
|
|
|
|
export async function updateSession(request) {
|
|
let supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
|
|
const supabase = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
|
supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
supabaseResponse.cookies.set(name, value, options)
|
|
)
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
// refreshing the auth token
|
|
await supabase.auth.getUser()
|
|
|
|
return supabaseResponse
|
|
}
|
|
```
|
|
|
|
</CH.Code>
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
Create a `middleware.ts` file at the project root and another one within the `utils/supabase` folder. The `utils/supabase` file contains the logic for updating the session. This is used by the `middleware.ts` file, which is a Next.js convention.
|
|
|
|
<CH.Code className="min-h-[30rem]">
|
|
|
|
```tsx middleware.ts
|
|
import { type NextRequest } from 'next/server'
|
|
import { updateSession } from '@/utils/supabase/middleware'
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
// update user's auth session
|
|
return await updateSession(request)
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except for the ones starting with:
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization files)
|
|
* - favicon.ico (favicon file)
|
|
* Feel free to modify this pattern to include more paths.
|
|
*/
|
|
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
|
],
|
|
}
|
|
```
|
|
|
|
```tsx utils/supabase/middleware.ts
|
|
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
|
import { NextResponse, type NextRequest } from 'next/server'
|
|
|
|
export async function updateSession(request: NextRequest) {
|
|
let supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
|
|
const supabase = createServerClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
{
|
|
cookies: {
|
|
getAll() {
|
|
return request.cookies.getAll()
|
|
},
|
|
setAll(cookiesToSet) {
|
|
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
|
supabaseResponse = NextResponse.next({
|
|
request,
|
|
})
|
|
cookiesToSet.forEach(({ name, value, options }) =>
|
|
supabaseResponse.cookies.set(name, value, options)
|
|
)
|
|
},
|
|
},
|
|
}
|
|
)
|
|
|
|
// refreshing the auth token
|
|
await supabase.auth.getUser()
|
|
|
|
return supabaseResponse
|
|
}
|
|
```
|
|
|
|
</CH.Code>
|
|
|
|
</TabPanel>
|
|
|
|
</Tabs>
|
|
|
|
## Set up a login page
|
|
|
|
### Login and Signup form
|
|
|
|
Create a login/signup page for your application:
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
Create a new folder named `login`, containing a `page.jsx` file with a login/signup form.
|
|
|
|
```jsx app/login/page.jsx
|
|
import { login, signup } from './actions'
|
|
|
|
export default function LoginPage() {
|
|
return (
|
|
<form>
|
|
<label htmlFor="email">Email:</label>
|
|
<input id="email" name="email" type="email" required />
|
|
<label htmlFor="password">Password:</label>
|
|
<input id="password" name="password" type="password" required />
|
|
<button formAction={login}>Log in</button>
|
|
<button formAction={signup}>Sign up</button>
|
|
</form>
|
|
)
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
Create a new folder named `login`, containing a `page.tsx` file with a login/signup form.
|
|
|
|
```tsx app/login/page.tsx
|
|
import { login, signup } from './actions'
|
|
|
|
export default function LoginPage() {
|
|
return (
|
|
<form>
|
|
<label htmlFor="email">Email:</label>
|
|
<input id="email" name="email" type="email" required />
|
|
<label htmlFor="password">Password:</label>
|
|
<input id="password" name="password" type="password" required />
|
|
<button formAction={login}>Log in</button>
|
|
<button formAction={signup}>Sign up</button>
|
|
</form>
|
|
)
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
</Tabs>
|
|
|
|
Navigate to `http://localhost:3000/login`. You should see your login form, but it's not yet hooked up to the actual login function. Next, you need to create the login/signup actions. They will:
|
|
|
|
- Retrieve the user's information.
|
|
- Send that information to Supabase as a signup request, which in turns will send a confirmation email.
|
|
- Handle any error that arises.
|
|
|
|
<Admonition type="caution">
|
|
|
|
Note that cookies is called before any calls to Supabase, which opts fetch calls out of Next.js's caching. This is important for authenticated data fetches, to ensure that users get access only to their own data.
|
|
|
|
See the Next.js docs to learn more about [opting out of data caching](https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching).
|
|
|
|
</Admonition>
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
<CH.Code className="max-h-[30rem]">
|
|
|
|
```js app/login/actions.js
|
|
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { redirect } from 'next/navigation'
|
|
|
|
import { createClient } from '@/utils/supabase/server'
|
|
|
|
export async function login(formData) {
|
|
const supabase = createClient()
|
|
|
|
// type-casting here for convenience
|
|
// in practice, you should validate your inputs
|
|
const data = {
|
|
email: formData.get('email'),
|
|
password: formData.get('password'),
|
|
}
|
|
|
|
const { error } = await supabase.auth.signInWithPassword(data)
|
|
|
|
if (error) {
|
|
redirect('/error')
|
|
}
|
|
|
|
revalidatePath('/', 'layout')
|
|
redirect('/account')
|
|
}
|
|
|
|
export async function signup(formData) {
|
|
const supabase = createClient()
|
|
|
|
const data = {
|
|
email: formData.get('email'),
|
|
password: formData.get('password'),
|
|
}
|
|
|
|
const { error } = await supabase.auth.signUp(data)
|
|
|
|
if (error) {
|
|
redirect('/error')
|
|
}
|
|
|
|
revalidatePath('/', 'layout')
|
|
redirect('/account')
|
|
}
|
|
```
|
|
|
|
```jsx app/error/page.jsx
|
|
export default function ErrorPage() {
|
|
return <p>Sorry, something went wrong</p>
|
|
}
|
|
```
|
|
|
|
</CH.Code>
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
<CH.Code className="max-h-[30rem]">
|
|
|
|
```ts app/login/actions.ts
|
|
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import { redirect } from 'next/navigation'
|
|
|
|
import { createClient } from '@/utils/supabase/server'
|
|
|
|
export async function login(formData: FormData) {
|
|
const supabase = createClient()
|
|
|
|
// type-casting here for convenience
|
|
// in practice, you should validate your inputs
|
|
const data = {
|
|
email: formData.get('email') as string,
|
|
password: formData.get('password') as string,
|
|
}
|
|
|
|
const { error } = await supabase.auth.signInWithPassword(data)
|
|
|
|
if (error) {
|
|
redirect('/error')
|
|
}
|
|
|
|
revalidatePath('/', 'layout')
|
|
redirect('/account')
|
|
}
|
|
|
|
export async function signup(formData: FormData) {
|
|
const supabase = createClient()
|
|
|
|
// type-casting here for convenience
|
|
// in practice, you should validate your inputs
|
|
const data = {
|
|
email: formData.get('email') as string,
|
|
password: formData.get('password') as string,
|
|
}
|
|
|
|
const { error } = await supabase.auth.signUp(data)
|
|
|
|
if (error) {
|
|
redirect('/error')
|
|
}
|
|
|
|
revalidatePath('/', 'layout')
|
|
redirect('/account')
|
|
}
|
|
```
|
|
|
|
```tsx app/error/page.tsx
|
|
export default function ErrorPage() {
|
|
return <p>Sorry, something went wrong</p>
|
|
}
|
|
```
|
|
|
|
</CH.Code>
|
|
|
|
</TabPanel>
|
|
|
|
When you enter your email and password, you will receive an email with the title **Confirm Your Signup**. Congrats 🎉!!!
|
|
|
|
</Tabs>
|
|
|
|
### Email template
|
|
|
|
Change the email template to support a server-side authentication flow.
|
|
|
|
Before we proceed, let's change the email template to support sending a token hash:
|
|
|
|
- Go to the [Auth templates](/dashboard/project/_/auth/templates) page in your dashboard.
|
|
- Select `Confirm signup` template.
|
|
- Change `{{ .ConfirmationURL }}` to `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email`.
|
|
|
|
<Admonition type="tip">
|
|
|
|
Did you know? You could also customize emails sent out to new users, including the email's looks, content, and query parameters. Check out the [settings of your project](/dashboard/project/_/auth/templates).
|
|
|
|
</Admonition>
|
|
|
|
### Confirmation endpoint
|
|
|
|
As we are working in a server-side rendering (SSR) environment, it is necessary to create a server endpoint responsible for exchanging the `token_hash` for a session.
|
|
|
|
In the following code snippet, we perform the following steps:
|
|
|
|
- Retrieve the code sent back from the Supabase Auth server using the `token_hash` query parameter.
|
|
- Exchange this code for a session, which we store in our chosen storage mechanism (in this case, cookies).
|
|
- Finally, we redirect the user to the `account` page.
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
```js app/auth/confirm/route.js
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
|
|
import { createClient } from '@/utils/supabase/server'
|
|
|
|
// Creating a handler to a GET request to route /auth/confirm
|
|
export async function GET(request) {
|
|
const { searchParams } = new URL(request.url)
|
|
const token_hash = searchParams.get('token_hash')
|
|
const type = searchParams.get('type')
|
|
const next = '/account'
|
|
|
|
// Create redirect link without the secret token
|
|
const redirectTo = request.nextUrl.clone()
|
|
redirectTo.pathname = next
|
|
redirectTo.searchParams.delete('token_hash')
|
|
redirectTo.searchParams.delete('type')
|
|
|
|
if (token_hash && type) {
|
|
const supabase = createClient()
|
|
|
|
const { error } = await supabase.auth.verifyOtp({
|
|
type,
|
|
token_hash,
|
|
})
|
|
if (!error) {
|
|
redirectTo.searchParams.delete('next')
|
|
return NextResponse.redirect(redirectTo)
|
|
}
|
|
}
|
|
|
|
// return the user to an error page with some instructions
|
|
redirectTo.pathname = '/error'
|
|
return NextResponse.redirect(redirectTo)
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
```ts app/auth/confirm/route.ts
|
|
import { type EmailOtpType } from '@supabase/supabase-js'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
|
|
import { createClient } from '@/utils/supabase/server'
|
|
|
|
// Creating a handler to a GET request to route /auth/confirm
|
|
export async function GET(request: NextRequest) {
|
|
const { searchParams } = new URL(request.url)
|
|
const token_hash = searchParams.get('token_hash')
|
|
const type = searchParams.get('type') as EmailOtpType | null
|
|
const next = '/account'
|
|
|
|
// Create redirect link without the secret token
|
|
const redirectTo = request.nextUrl.clone()
|
|
redirectTo.pathname = next
|
|
redirectTo.searchParams.delete('token_hash')
|
|
redirectTo.searchParams.delete('type')
|
|
|
|
if (token_hash && type) {
|
|
const supabase = createClient()
|
|
|
|
const { error } = await supabase.auth.verifyOtp({
|
|
type,
|
|
token_hash,
|
|
})
|
|
if (!error) {
|
|
redirectTo.searchParams.delete('next')
|
|
return NextResponse.redirect(redirectTo)
|
|
}
|
|
}
|
|
|
|
// return the user to an error page with some instructions
|
|
redirectTo.pathname = '/error'
|
|
return NextResponse.redirect(redirectTo)
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
</Tabs>
|
|
|
|
### Account page
|
|
|
|
After a user is signed in we can allow them to edit their profile details and manage their account.
|
|
|
|
Let's create a new component for that called `AccountForm` within the `app/account` folder.
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
<CH.Code className="max-h-[40rem]">
|
|
|
|
```jsx app/account/account-form.jsx
|
|
'use client'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { createClient } from '@/utils/supabase/client'
|
|
|
|
export default function AccountForm({ user }) {
|
|
const supabase = createClient()
|
|
const [loading, setLoading] = useState(true)
|
|
const [fullname, setFullname] = useState(null)
|
|
const [username, setUsername] = useState(null)
|
|
const [website, setWebsite] = useState(null)
|
|
const [avatar_url, setAvatarUrl] = useState(null)
|
|
|
|
const getProfile = useCallback(async () => {
|
|
try {
|
|
setLoading(true)
|
|
|
|
const { data, error, status } = await supabase
|
|
.from('profiles')
|
|
.select(`full_name, username, website, avatar_url`)
|
|
.eq('id', user?.id)
|
|
.single()
|
|
|
|
if (error && status !== 406) {
|
|
throw error
|
|
}
|
|
|
|
if (data) {
|
|
setFullname(data.full_name)
|
|
setUsername(data.username)
|
|
setWebsite(data.website)
|
|
setAvatarUrl(data.avatar_url)
|
|
}
|
|
} catch (error) {
|
|
alert('Error loading user data!')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [user, supabase])
|
|
|
|
useEffect(() => {
|
|
getProfile()
|
|
}, [user, getProfile])
|
|
|
|
async function updateProfile({ username, website, avatar_url }) {
|
|
try {
|
|
setLoading(true)
|
|
|
|
const { error } = await supabase.from('profiles').upsert({
|
|
id: user?.id,
|
|
full_name: fullname,
|
|
username,
|
|
website,
|
|
avatar_url,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
if (error) throw error
|
|
alert('Profile updated!')
|
|
} catch (error) {
|
|
alert('Error updating the data!')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="form-widget">
|
|
<div>
|
|
<label htmlFor="email">Email</label>
|
|
<input id="email" type="text" value={user?.email} disabled />
|
|
</div>
|
|
<div>
|
|
<label htmlFor="fullName">Full Name</label>
|
|
<input
|
|
id="fullName"
|
|
type="text"
|
|
value={fullname || ''}
|
|
onChange={(e) => setFullname(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="username">Username</label>
|
|
<input
|
|
id="username"
|
|
type="text"
|
|
value={username || ''}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="website">Website</label>
|
|
<input
|
|
id="website"
|
|
type="url"
|
|
value={website || ''}
|
|
onChange={(e) => setWebsite(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
className="button primary block"
|
|
onClick={() => updateProfile({ fullname, username, website, avatar_url })}
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Loading ...' : 'Update'}
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<form action="/auth/signout" method="post">
|
|
<button className="button block" type="submit">
|
|
Sign out
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
</CH.Code>
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
<CH.Code className="max-h-[40rem]">
|
|
|
|
```tsx app/account/account-form.tsx
|
|
'use client'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import { createClient } from '@/utils/supabase/client'
|
|
import { type User } from '@supabase/supabase-js'
|
|
|
|
export default function AccountForm({ user }: { user: User | null }) {
|
|
const supabase = createClient()
|
|
const [loading, setLoading] = useState(true)
|
|
const [fullname, setFullname] = useState<string | null>(null)
|
|
const [username, setUsername] = useState<string | null>(null)
|
|
const [website, setWebsite] = useState<string | null>(null)
|
|
const [avatar_url, setAvatarUrl] = useState<string | null>(null)
|
|
|
|
const getProfile = useCallback(async () => {
|
|
try {
|
|
setLoading(true)
|
|
|
|
const { data, error, status } = await supabase
|
|
.from('profiles')
|
|
.select(`full_name, username, website, avatar_url`)
|
|
.eq('id', user?.id)
|
|
.single()
|
|
|
|
if (error && status !== 406) {
|
|
console.log(error)
|
|
throw error
|
|
}
|
|
|
|
if (data) {
|
|
setFullname(data.full_name)
|
|
setUsername(data.username)
|
|
setWebsite(data.website)
|
|
setAvatarUrl(data.avatar_url)
|
|
}
|
|
} catch (error) {
|
|
alert('Error loading user data!')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [user, supabase])
|
|
|
|
useEffect(() => {
|
|
getProfile()
|
|
}, [user, getProfile])
|
|
|
|
async function updateProfile({
|
|
username,
|
|
website,
|
|
avatar_url,
|
|
}: {
|
|
username: string | null
|
|
fullname: string | null
|
|
website: string | null
|
|
avatar_url: string | null
|
|
}) {
|
|
try {
|
|
setLoading(true)
|
|
|
|
const { error } = await supabase.from('profiles').upsert({
|
|
id: user?.id as string,
|
|
full_name: fullname,
|
|
username,
|
|
website,
|
|
avatar_url,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
if (error) throw error
|
|
alert('Profile updated!')
|
|
} catch (error) {
|
|
alert('Error updating the data!')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="form-widget">
|
|
<div>
|
|
<label htmlFor="email">Email</label>
|
|
<input id="email" type="text" value={user?.email} disabled />
|
|
</div>
|
|
<div>
|
|
<label htmlFor="fullName">Full Name</label>
|
|
<input
|
|
id="fullName"
|
|
type="text"
|
|
value={fullname || ''}
|
|
onChange={(e) => setFullname(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="username">Username</label>
|
|
<input
|
|
id="username"
|
|
type="text"
|
|
value={username || ''}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label htmlFor="website">Website</label>
|
|
<input
|
|
id="website"
|
|
type="url"
|
|
value={website || ''}
|
|
onChange={(e) => setWebsite(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<button
|
|
className="button primary block"
|
|
onClick={() => updateProfile({ fullname, username, website, avatar_url })}
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Loading ...' : 'Update'}
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<form action="/auth/signout" method="post">
|
|
<button className="button block" type="submit">
|
|
Sign out
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
</CH.Code>
|
|
|
|
</TabPanel>
|
|
|
|
</Tabs>
|
|
|
|
Create an account page for the `AccountForm` component we just created
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
```jsx app/account/page.jsx
|
|
import AccountForm from './account-form'
|
|
import { createClient } from '@/utils/supabase/server'
|
|
|
|
export default async function Account() {
|
|
const supabase = createClient()
|
|
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser()
|
|
|
|
return <AccountForm user={user} />
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
```tsx app/account/page.tsx
|
|
import AccountForm from './account-form'
|
|
import { createClient } from '@/utils/supabase/server'
|
|
|
|
export default async function Account() {
|
|
const supabase = createClient()
|
|
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser()
|
|
|
|
return <AccountForm user={user} />
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
</Tabs>
|
|
|
|
### Sign out
|
|
|
|
Let's create a route handler to handle the signout from the server side. Make sure to check if the user is logged in first!
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
```js app/auth/signout/route.js
|
|
import { createClient } from '@/utils/supabase/server'
|
|
import { revalidatePath } from 'next/cache'
|
|
import { NextResponse } from 'next/server'
|
|
|
|
export async function POST(req) {
|
|
const supabase = createClient()
|
|
|
|
// Check if a user's logged in
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser()
|
|
|
|
if (user) {
|
|
await supabase.auth.signOut()
|
|
}
|
|
|
|
revalidatePath('/', 'layout')
|
|
return NextResponse.redirect(new URL('/login', req.url), {
|
|
status: 302,
|
|
})
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
```ts app/auth/signout/route.ts
|
|
import { createClient } from '@/utils/supabase/server'
|
|
import { revalidatePath } from 'next/cache'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const supabase = createClient()
|
|
|
|
// Check if a user's logged in
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser()
|
|
|
|
if (user) {
|
|
await supabase.auth.signOut()
|
|
}
|
|
|
|
revalidatePath('/', 'layout')
|
|
return NextResponse.redirect(new URL('/login', req.url), {
|
|
status: 302,
|
|
})
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
</Tabs>
|
|
|
|
### Launch!
|
|
|
|
Now that we have all the pages, route handlers and components in place, let's run this in a terminal window:
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
|
|
And then open the browser to [localhost:3000](http://localhost:3000) and you should see the completed app.
|
|
|
|
## Bonus: Profile photos
|
|
|
|
Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like
|
|
photos and videos.
|
|
|
|
### Create an upload widget
|
|
|
|
Let's create an avatar widget for the user so that they can upload a profile photo. We can start by creating a new component:
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
```jsx app/account/avatar.jsx
|
|
'use client'
|
|
import React, { useEffect, useState } from 'react'
|
|
import { createClient } from '@/utils/supabase/client'
|
|
import Image from 'next/image'
|
|
|
|
export default function Avatar({ uid, url, size, onUpload }) {
|
|
const supabase = createClient()
|
|
const [avatarUrl, setAvatarUrl] = useState(url)
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
async function downloadImage(path) {
|
|
try {
|
|
const { data, error } = await supabase.storage.from('avatars').download(path)
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
const url = URL.createObjectURL(data)
|
|
setAvatarUrl(url)
|
|
} catch (error) {
|
|
console.log('Error downloading image: ', error)
|
|
}
|
|
}
|
|
|
|
if (url) downloadImage(url)
|
|
}, [url, supabase])
|
|
|
|
const uploadAvatar = async (event) => {
|
|
try {
|
|
setUploading(true)
|
|
|
|
if (!event.target.files || event.target.files.length === 0) {
|
|
throw new Error('You must select an image to upload.')
|
|
}
|
|
|
|
const file = event.target.files[0]
|
|
const fileExt = file.name.split('.').pop()
|
|
const filePath = `${uid}-${Math.random()}.${fileExt}`
|
|
|
|
const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
|
|
|
|
if (uploadError) {
|
|
throw uploadError
|
|
}
|
|
|
|
onUpload(filePath)
|
|
} catch (error) {
|
|
alert('Error uploading avatar!')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{avatarUrl ? (
|
|
<Image
|
|
width={size}
|
|
height={size}
|
|
src={avatarUrl}
|
|
alt="Avatar"
|
|
className="avatar image"
|
|
style={{ height: size, width: size }}
|
|
/>
|
|
) : (
|
|
<div className="avatar no-image" style={{ height: size, width: size }} />
|
|
)}
|
|
<div style={{ width: size }}>
|
|
<label className="button primary block" htmlFor="single">
|
|
{uploading ? 'Uploading ...' : 'Upload'}
|
|
</label>
|
|
<input
|
|
style={{
|
|
visibility: 'hidden',
|
|
position: 'absolute',
|
|
}}
|
|
type="file"
|
|
id="single"
|
|
accept="image/*"
|
|
onChange={uploadAvatar}
|
|
disabled={uploading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
```tsx app/account/avatar.tsx
|
|
'use client'
|
|
import React, { useEffect, useState } from 'react'
|
|
import { createClient } from '@/utils/supabase/client'
|
|
import Image from 'next/image'
|
|
|
|
export default function Avatar({
|
|
uid,
|
|
url,
|
|
size,
|
|
onUpload,
|
|
}: {
|
|
uid: string | null
|
|
url: string | null
|
|
size: number
|
|
onUpload: (url: string) => void
|
|
}) {
|
|
const supabase = createClient()
|
|
const [avatarUrl, setAvatarUrl] = useState<string | null>(url)
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
async function downloadImage(path: string) {
|
|
try {
|
|
const { data, error } = await supabase.storage.from('avatars').download(path)
|
|
if (error) {
|
|
throw error
|
|
}
|
|
|
|
const url = URL.createObjectURL(data)
|
|
setAvatarUrl(url)
|
|
} catch (error) {
|
|
console.log('Error downloading image: ', error)
|
|
}
|
|
}
|
|
|
|
if (url) downloadImage(url)
|
|
}, [url, supabase])
|
|
|
|
const uploadAvatar: React.ChangeEventHandler<HTMLInputElement> = async (event) => {
|
|
try {
|
|
setUploading(true)
|
|
|
|
if (!event.target.files || event.target.files.length === 0) {
|
|
throw new Error('You must select an image to upload.')
|
|
}
|
|
|
|
const file = event.target.files[0]
|
|
const fileExt = file.name.split('.').pop()
|
|
const filePath = `${uid}-${Math.random()}.${fileExt}`
|
|
|
|
const { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)
|
|
|
|
if (uploadError) {
|
|
throw uploadError
|
|
}
|
|
|
|
onUpload(filePath)
|
|
} catch (error) {
|
|
alert('Error uploading avatar!')
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
{avatarUrl ? (
|
|
<Image
|
|
width={size}
|
|
height={size}
|
|
src={avatarUrl}
|
|
alt="Avatar"
|
|
className="avatar image"
|
|
style={{ height: size, width: size }}
|
|
/>
|
|
) : (
|
|
<div className="avatar no-image" style={{ height: size, width: size }} />
|
|
)}
|
|
<div style={{ width: size }}>
|
|
<label className="button primary block" htmlFor="single">
|
|
{uploading ? 'Uploading ...' : 'Upload'}
|
|
</label>
|
|
<input
|
|
style={{
|
|
visibility: 'hidden',
|
|
position: 'absolute',
|
|
}}
|
|
type="file"
|
|
id="single"
|
|
accept="image/*"
|
|
onChange={uploadAvatar}
|
|
disabled={uploading}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
</Tabs>
|
|
|
|
### Add the new widget
|
|
|
|
And then we can add the widget to the `AccountForm` component:
|
|
|
|
<Tabs
|
|
scrollable
|
|
size="small"
|
|
type="underlined"
|
|
defaultActiveId="js"
|
|
queryGroup="language"
|
|
>
|
|
|
|
<TabPanel id="js" label="JavaScript">
|
|
|
|
```jsx app/account/account-form.jsx
|
|
// Import the new component
|
|
import Avatar from './avatar'
|
|
|
|
// ...
|
|
|
|
return (
|
|
<div className="form-widget">
|
|
{/* Add to the body */}
|
|
<Avatar
|
|
uid={user?.id}
|
|
url={avatar_url}
|
|
size={150}
|
|
onUpload={(url) => {
|
|
setAvatarUrl(url)
|
|
updateProfile({ fullname, username, website, avatar_url: url })
|
|
}}
|
|
/>
|
|
{/* ... */}
|
|
</div>
|
|
)
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
<TabPanel id="ts" label="TypeScript">
|
|
|
|
```tsx app/account/account-form.tsx
|
|
// Import the new component
|
|
import Avatar from './avatar'
|
|
|
|
// ...
|
|
|
|
return (
|
|
<div className="form-widget">
|
|
{/* Add to the body */}
|
|
<Avatar
|
|
uid={user?.id ?? null}
|
|
url={avatar_url}
|
|
size={150}
|
|
onUpload={(url) => {
|
|
setAvatarUrl(url)
|
|
updateProfile({ fullname, username, website, avatar_url: url })
|
|
}}
|
|
/>
|
|
{/* ... */}
|
|
</div>
|
|
)
|
|
```
|
|
|
|
</TabPanel>
|
|
|
|
</Tabs>
|
|
|
|
At this stage you have a fully functional application!
|
|
|
|
## See also
|
|
|
|
- See the complete [example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/nextjs-user-management) and deploy it to Vercel
|
|
- [Build a Twitter Clone with the Next.js App Router and Supabase - free egghead course](https://egghead.io/courses/build-a-twitter-clone-with-the-next-js-app-router-and-supabase-19bebadb)
|
|
- Explore the [pre-built Auth UI for React](/docs/guides/auth/auth-helpers/auth-ui)
|
|
- Explore the [Auth Helpers for Next.js](/docs/guides/auth/auth-helpers/nextjs)
|
|
- Explore the [Supabase Cache Helpers](https://github.com/psteinroe/supabase-cache-helpers)
|
|
- See the [Next.js Subscription Payments Starter](https://github.com/vercel/nextjs-subscription-payments) template on GitHub
|