commit 8a1a9546032804aa9e50d9630ffe9f18d7b3efea Author: tockawaffle <98602240+tockawaffle@users.noreply.github.com> Date: Tue Dec 10 16:52:53 2024 +0000 Initial commit Created from https://vercel.com/new diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6937031 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Update these with your Supabase details from your project settings > API +# https://app.supabase.com/project/_/settings/api +NEXT_PUBLIC_SUPABASE_URL=your-project-url +NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00bba9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..53c555a --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ + + Next.js and Supabase Starter Kit - the fastest way to build apps with Next.js and Supabase +

Next.js and Supabase Starter Kit

+
+ +

+ The fastest way to build apps with Next.js and Supabase +

+ +

+ Features · + Demo · + Deploy to Vercel · + Clone and run locally · + Feedback and issues + More Examples +

+
+ +## Features + +- Works across the entire [Next.js](https://nextjs.org) stack + - App Router + - Pages Router + - Middleware + - Client + - Server + - It just works! +- supabase-ssr. A package to configure Supabase Auth to use cookies +- Styling with [Tailwind CSS](https://tailwindcss.com) +- Components with [shadcn/ui](https://ui.shadcn.com/) +- Optional deployment with [Supabase Vercel Integration and Vercel deploy](#deploy-your-own) + - Environment variables automatically assigned to Vercel project + +## Demo + +You can view a fully working demo at [demo-nextjs-with-supabase.vercel.app](https://demo-nextjs-with-supabase.vercel.app/). + +## Deploy to Vercel + +Vercel deployment will guide you through creating a Supabase account and project. + +After installation of the Supabase integration, all relevant environment variables will be assigned to the project so the deployment is fully functioning. + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&project-name=nextjs-with-supabase&repository-name=nextjs-with-supabase&demo-title=nextjs-with-supabase&demo-description=This+starter+configures+Supabase+Auth+to+use+cookies%2C+making+the+user%27s+session+available+throughout+the+entire+Next.js+app+-+Client+Components%2C+Server+Components%2C+Route+Handlers%2C+Server+Actions+and+Middleware.&demo-url=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2F&external-id=https%3A%2F%2Fgithub.com%2Fvercel%2Fnext.js%2Ftree%2Fcanary%2Fexamples%2Fwith-supabase&demo-image=https%3A%2F%2Fdemo-nextjs-with-supabase.vercel.app%2Fopengraph-image.png) + +The above will also clone the Starter kit to your GitHub, you can clone that locally and develop locally. + +If you wish to just develop locally and not deploy to Vercel, [follow the steps below](#clone-and-run-locally). + +## Clone and run locally + +1. You'll first need a Supabase project which can be made [via the Supabase dashboard](https://database.new) + +2. Create a Next.js app using the Supabase Starter template npx command + + ```bash + npx create-next-app -e with-supabase + ``` + +3. Use `cd` to change into the app's directory + + ```bash + cd name-of-new-app + ``` + +4. Rename `.env.example` to `.env.local` and update the following: + + ``` + NEXT_PUBLIC_SUPABASE_URL=[INSERT SUPABASE PROJECT URL] + NEXT_PUBLIC_SUPABASE_ANON_KEY=[INSERT SUPABASE PROJECT API ANON KEY] + ``` + + Both `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` can be found in [your Supabase project's API settings](https://app.supabase.com/project/_/settings/api) + +5. You can now run the Next.js local development server: + + ```bash + npm run dev + ``` + + The starter kit should now be running on [localhost:3000](http://localhost:3000/). + +6. This template comes with the default shadcn/ui style initialized. If you instead want other ui.shadcn styles, delete `components.json` and [re-install shadcn/ui](https://ui.shadcn.com/docs/installation/next) + +> Check out [the docs for Local Development](https://supabase.com/docs/guides/getting-started/local-development) to also run Supabase locally. + +## Feedback and issues + +Please file feedback and issues over on the [Supabase GitHub org](https://github.com/supabase/supabase/issues/new/choose). + +## More Supabase examples + +- [Next.js Subscription Payments Starter](https://github.com/vercel/nextjs-subscription-payments) +- [Cookie-based Auth and the Next.js 13 App Router (free course)](https://youtube.com/playlist?list=PL5S4mPUpp4OtMhpnp93EFSo42iQ40XjbF) +- [Supabase Auth and the Next.js App Router](https://github.com/supabase/supabase/tree/master/examples/auth/nextjs) diff --git a/app/(auth-pages)/forgot-password/page.tsx b/app/(auth-pages)/forgot-password/page.tsx new file mode 100644 index 0000000..bcf9725 --- /dev/null +++ b/app/(auth-pages)/forgot-password/page.tsx @@ -0,0 +1,37 @@ +import { forgotPasswordAction } from "@/app/actions"; +import { FormMessage, Message } from "@/components/form-message"; +import { SubmitButton } from "@/components/submit-button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; +import { SmtpMessage } from "../smtp-message"; + +export default async function ForgotPassword(props: { + searchParams: Promise; +}) { + const searchParams = await props.searchParams; + return ( + <> +
+
+

Reset Password

+

+ Already have an account?{" "} + + Sign in + +

+
+
+ + + + Reset Password + + +
+
+ + + ); +} diff --git a/app/(auth-pages)/layout.tsx b/app/(auth-pages)/layout.tsx new file mode 100644 index 0000000..e038de1 --- /dev/null +++ b/app/(auth-pages)/layout.tsx @@ -0,0 +1,9 @@ +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
{children}
+ ); +} diff --git a/app/(auth-pages)/sign-in/page.tsx b/app/(auth-pages)/sign-in/page.tsx new file mode 100644 index 0000000..7628cc7 --- /dev/null +++ b/app/(auth-pages)/sign-in/page.tsx @@ -0,0 +1,44 @@ +import { signInAction } from "@/app/actions"; +import { FormMessage, Message } from "@/components/form-message"; +import { SubmitButton } from "@/components/submit-button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; + +export default async function Login(props: { searchParams: Promise }) { + const searchParams = await props.searchParams; + return ( +
+

Sign in

+

+ Don't have an account?{" "} + + Sign up + +

+
+ + +
+ + + Forgot Password? + +
+ + + Sign in + + +
+
+ ); +} diff --git a/app/(auth-pages)/sign-up/page.tsx b/app/(auth-pages)/sign-up/page.tsx new file mode 100644 index 0000000..31b5a6d --- /dev/null +++ b/app/(auth-pages)/sign-up/page.tsx @@ -0,0 +1,51 @@ +import { signUpAction } from "@/app/actions"; +import { FormMessage, Message } from "@/components/form-message"; +import { SubmitButton } from "@/components/submit-button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import Link from "next/link"; +import { SmtpMessage } from "../smtp-message"; + +export default async function Signup(props: { + searchParams: Promise; +}) { + const searchParams = await props.searchParams; + if ("message" in searchParams) { + return ( +
+ +
+ ); + } + + return ( + <> +
+

Sign up

+

+ Already have an account?{" "} + + Sign in + +

+
+ + + + + + Sign up + + +
+
+ + + ); +} diff --git a/app/(auth-pages)/smtp-message.tsx b/app/(auth-pages)/smtp-message.tsx new file mode 100644 index 0000000..84c21fc --- /dev/null +++ b/app/(auth-pages)/smtp-message.tsx @@ -0,0 +1,25 @@ +import { ArrowUpRight, InfoIcon } from "lucide-react"; +import Link from "next/link"; + +export function SmtpMessage() { + return ( +
+ +
+ + Note: Emails are rate limited. Enable Custom SMTP to + increase the rate limit. + +
+ + Learn more + +
+
+
+ ); +} diff --git a/app/actions.ts b/app/actions.ts new file mode 100644 index 0000000..dbf8a26 --- /dev/null +++ b/app/actions.ts @@ -0,0 +1,134 @@ +"use server"; + +import { encodedRedirect } from "@/utils/utils"; +import { createClient } from "@/utils/supabase/server"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; + +export const signUpAction = async (formData: FormData) => { + const email = formData.get("email")?.toString(); + const password = formData.get("password")?.toString(); + const supabase = await createClient(); + const origin = (await headers()).get("origin"); + + if (!email || !password) { + return encodedRedirect( + "error", + "/sign-up", + "Email and password are required", + ); + } + + const { error } = await supabase.auth.signUp({ + email, + password, + options: { + emailRedirectTo: `${origin}/auth/callback`, + }, + }); + + if (error) { + console.error(error.code + " " + error.message); + return encodedRedirect("error", "/sign-up", error.message); + } else { + return encodedRedirect( + "success", + "/sign-up", + "Thanks for signing up! Please check your email for a verification link.", + ); + } +}; + +export const signInAction = async (formData: FormData) => { + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const supabase = await createClient(); + + const { error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + return encodedRedirect("error", "/sign-in", error.message); + } + + return redirect("/protected"); +}; + +export const forgotPasswordAction = async (formData: FormData) => { + const email = formData.get("email")?.toString(); + const supabase = await createClient(); + const origin = (await headers()).get("origin"); + const callbackUrl = formData.get("callbackUrl")?.toString(); + + if (!email) { + return encodedRedirect("error", "/forgot-password", "Email is required"); + } + + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${origin}/auth/callback?redirect_to=/protected/reset-password`, + }); + + if (error) { + console.error(error.message); + return encodedRedirect( + "error", + "/forgot-password", + "Could not reset password", + ); + } + + if (callbackUrl) { + return redirect(callbackUrl); + } + + return encodedRedirect( + "success", + "/forgot-password", + "Check your email for a link to reset your password.", + ); +}; + +export const resetPasswordAction = async (formData: FormData) => { + const supabase = await createClient(); + + const password = formData.get("password") as string; + const confirmPassword = formData.get("confirmPassword") as string; + + if (!password || !confirmPassword) { + encodedRedirect( + "error", + "/protected/reset-password", + "Password and confirm password are required", + ); + } + + if (password !== confirmPassword) { + encodedRedirect( + "error", + "/protected/reset-password", + "Passwords do not match", + ); + } + + const { error } = await supabase.auth.updateUser({ + password: password, + }); + + if (error) { + encodedRedirect( + "error", + "/protected/reset-password", + "Password update failed", + ); + } + + encodedRedirect("success", "/protected/reset-password", "Password updated"); +}; + +export const signOutAction = async () => { + const supabase = await createClient(); + await supabase.auth.signOut(); + return redirect("/sign-in"); +}; diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts new file mode 100644 index 0000000..dd415a4 --- /dev/null +++ b/app/auth/callback/route.ts @@ -0,0 +1,24 @@ +import { createClient } from "@/utils/supabase/server"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + // The `/auth/callback` route is required for the server-side auth flow implemented + // by the SSR package. It exchanges an auth code for the user's session. + // https://supabase.com/docs/guides/auth/server-side/nextjs + const requestUrl = new URL(request.url); + const code = requestUrl.searchParams.get("code"); + const origin = requestUrl.origin; + const redirectTo = requestUrl.searchParams.get("redirect_to")?.toString(); + + if (code) { + const supabase = await createClient(); + await supabase.auth.exchangeCodeForSession(code); + } + + if (redirectTo) { + return NextResponse.redirect(`${origin}${redirectTo}`); + } + + // URL to redirect to after sign up process completes + return NextResponse.redirect(`${origin}/protected`); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..f450d1e --- /dev/null +++ b/app/globals.css @@ -0,0 +1,69 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --radius: 0.5rem; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..8d80488 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,72 @@ +import DeployButton from "@/components/deploy-button"; +import { EnvVarWarning } from "@/components/env-var-warning"; +import HeaderAuth from "@/components/header-auth"; +import { ThemeSwitcher } from "@/components/theme-switcher"; +import { hasEnvVars } from "@/utils/supabase/check-env-vars"; +import { GeistSans } from "geist/font/sans"; +import { ThemeProvider } from "next-themes"; +import Link from "next/link"; +import "./globals.css"; + +const defaultUrl = process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(defaultUrl), + title: "Next.js and Supabase Starter Kit", + description: "The fastest way to build apps with Next.js and Supabase", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
+
+ +
+ {children} +
+ + +
+
+
+ + + ); +} diff --git a/app/opengraph-image.png b/app/opengraph-image.png new file mode 100644 index 0000000..57595e6 Binary files /dev/null and b/app/opengraph-image.png differ diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..7e2e91a --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,16 @@ +import Hero from "@/components/hero"; +import ConnectSupabaseSteps from "@/components/tutorial/connect-supabase-steps"; +import SignUpUserSteps from "@/components/tutorial/sign-up-user-steps"; +import { hasEnvVars } from "@/utils/supabase/check-env-vars"; + +export default async function Index() { + return ( + <> + +
+

Next steps

+ {hasEnvVars ? : } +
+ + ); +} diff --git a/app/protected/page.tsx b/app/protected/page.tsx new file mode 100644 index 0000000..5508aba --- /dev/null +++ b/app/protected/page.tsx @@ -0,0 +1,38 @@ +import FetchDataSteps from "@/components/tutorial/fetch-data-steps"; +import { createClient } from "@/utils/supabase/server"; +import { InfoIcon } from "lucide-react"; +import { redirect } from "next/navigation"; + +export default async function ProtectedPage() { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return redirect("/sign-in"); + } + + return ( +
+
+
+ + This is a protected page that you can only see as an authenticated + user +
+
+
+

Your user details

+
+          {JSON.stringify(user, null, 2)}
+        
+
+
+

Next steps

+ +
+
+ ); +} diff --git a/app/protected/reset-password/page.tsx b/app/protected/reset-password/page.tsx new file mode 100644 index 0000000..9cd7084 --- /dev/null +++ b/app/protected/reset-password/page.tsx @@ -0,0 +1,37 @@ +import { resetPasswordAction } from "@/app/actions"; +import { FormMessage, Message } from "@/components/form-message"; +import { SubmitButton } from "@/components/submit-button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default async function ResetPassword(props: { + searchParams: Promise; +}) { + const searchParams = await props.searchParams; + return ( +
+

Reset password

+

+ Please enter your new password below. +

+ + + + + + Reset password + + + + ); +} diff --git a/app/twitter-image.png b/app/twitter-image.png new file mode 100644 index 0000000..57595e6 Binary files /dev/null and b/app/twitter-image.png differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..ec9676b --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/components/deploy-button.tsx b/components/deploy-button.tsx new file mode 100644 index 0000000..8a5a192 --- /dev/null +++ b/components/deploy-button.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { Button } from "./ui/button"; + +export default function DeployButton() { + return ( + <> + + + + + ); +} diff --git a/components/env-var-warning.tsx b/components/env-var-warning.tsx new file mode 100644 index 0000000..b6a193f --- /dev/null +++ b/components/env-var-warning.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; + +export function EnvVarWarning() { + return ( +
+ + Supabase environment variables required + +
+ + +
+
+ ); +} diff --git a/components/form-message.tsx b/components/form-message.tsx new file mode 100644 index 0000000..547fb9f --- /dev/null +++ b/components/form-message.tsx @@ -0,0 +1,24 @@ +export type Message = + | { success: string } + | { error: string } + | { message: string }; + +export function FormMessage({ message }: { message: Message }) { + return ( +
+ {"success" in message && ( +
+ {message.success} +
+ )} + {"error" in message && ( +
+ {message.error} +
+ )} + {"message" in message && ( +
{message.message}
+ )} +
+ ); +} diff --git a/components/header-auth.tsx b/components/header-auth.tsx new file mode 100644 index 0000000..eb9d65c --- /dev/null +++ b/components/header-auth.tsx @@ -0,0 +1,70 @@ +import { signOutAction } from "@/app/actions"; +import { hasEnvVars } from "@/utils/supabase/check-env-vars"; +import Link from "next/link"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { createClient } from "@/utils/supabase/server"; + +export default async function AuthButton() { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!hasEnvVars) { + return ( + <> +
+
+ + Please update .env.local file with anon key and url + +
+
+ + +
+
+ + ); + } + return user ? ( +
+ Hey, {user.email}! +
+ +
+
+ ) : ( +
+ + +
+ ); +} diff --git a/components/hero.tsx b/components/hero.tsx new file mode 100644 index 0000000..6afca6b --- /dev/null +++ b/components/hero.tsx @@ -0,0 +1,44 @@ +import NextLogo from "./next-logo"; +import SupabaseLogo from "./supabase-logo"; + +export default function Header() { + return ( +
+
+ + + + + + + +
+

Supabase and Next.js Starter Template

+

+ The fastest way to build apps with{" "} + + Supabase + {" "} + and{" "} + + Next.js + +

+
+
+ ); +} diff --git a/components/next-logo.tsx b/components/next-logo.tsx new file mode 100644 index 0000000..1655582 --- /dev/null +++ b/components/next-logo.tsx @@ -0,0 +1,46 @@ +export default function NextLogo() { + return ( + + + + + + + + + + + ); +} diff --git a/components/submit-button.tsx b/components/submit-button.tsx new file mode 100644 index 0000000..c1cd9f8 --- /dev/null +++ b/components/submit-button.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { type ComponentProps } from "react"; +import { useFormStatus } from "react-dom"; + +type Props = ComponentProps & { + pendingText?: string; +}; + +export function SubmitButton({ + children, + pendingText = "Submitting...", + ...props +}: Props) { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/components/supabase-logo.tsx b/components/supabase-logo.tsx new file mode 100644 index 0000000..96a56a5 --- /dev/null +++ b/components/supabase-logo.tsx @@ -0,0 +1,102 @@ +export default function SupabaseLogo() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/components/theme-switcher.tsx b/components/theme-switcher.tsx new file mode 100644 index 0000000..d838e40 --- /dev/null +++ b/components/theme-switcher.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Laptop, Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +const ThemeSwitcher = () => { + const [mounted, setMounted] = useState(false); + const { theme, setTheme } = useTheme(); + + // useEffect only runs on the client, so now we can safely show the UI + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + const ICON_SIZE = 16; + + return ( + + + + + + setTheme(e)} + > + + {" "} + Light + + + {" "} + Dark + + + {" "} + System + + + + + ); +}; + +export { ThemeSwitcher }; diff --git a/components/tutorial/code-block.tsx b/components/tutorial/code-block.tsx new file mode 100644 index 0000000..9f1b13d --- /dev/null +++ b/components/tutorial/code-block.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; + +const CopyIcon = () => ( + + + + +); + +const CheckIcon = () => ( + + + +); + +export function CodeBlock({ code }: { code: string }) { + const [icon, setIcon] = useState(CopyIcon); + + const copy = async () => { + await navigator?.clipboard?.writeText(code); + setIcon(CheckIcon); + setTimeout(() => setIcon(CopyIcon), 2000); + }; + + return ( +
+      
+      {code}
+    
+ ); +} diff --git a/components/tutorial/connect-supabase-steps.tsx b/components/tutorial/connect-supabase-steps.tsx new file mode 100644 index 0000000..04ca37f --- /dev/null +++ b/components/tutorial/connect-supabase-steps.tsx @@ -0,0 +1,62 @@ +import { TutorialStep } from "./tutorial-step"; + +export default function ConnectSupabaseSteps() { + return ( +
    + +

    + Head over to{" "} + + database.new + {" "} + and create a new Supabase project. +

    +
    + + +

    + Rename the{" "} + + .env.example + {" "} + file in your Next.js app to{" "} + + .env.local + {" "} + and populate with values from{" "} + + your Supabase project's API Settings + + . +

    +
    + + +

    + You may need to quit your Next.js development server and run{" "} + + npm run dev + {" "} + again to load the new environment variables. +

    +
    + + +

    + You may need to refresh the page for Next.js to load the new + environment variables. +

    +
    +
+ ); +} diff --git a/components/tutorial/fetch-data-steps.tsx b/components/tutorial/fetch-data-steps.tsx new file mode 100644 index 0000000..f0193fe --- /dev/null +++ b/components/tutorial/fetch-data-steps.tsx @@ -0,0 +1,96 @@ +import { TutorialStep } from "./tutorial-step"; +import { CodeBlock } from "./code-block"; + +const create = `create table notes ( + id bigserial primary key, + title text +); + +insert into notes(title) +values + ('Today I created a Supabase project.'), + ('I added some data and queried it from Next.js.'), + ('It was awesome!'); +`.trim(); + +const server = `import { createClient } from '@/utils/supabase/server' + +export default async function Page() { + const supabase = createClient() + const { data: notes } = await supabase.from('notes').select() + + return
{JSON.stringify(notes, null, 2)}
+} +`.trim(); + +const client = `'use client' + +import { createClient } from '@/utils/supabase/client' +import { useEffect, useState } from 'react' + +export default function Page() { + const [notes, setNotes] = useState(null) + const supabase = createClient() + + useEffect(() => { + const getData = async () => { + const { data } = await supabase.from('notes').select() + setNotes(data) + } + getData() + }, []) + + return
{JSON.stringify(notes, null, 2)}
+} +`.trim(); + +export default function FetchDataSteps() { + return ( +
    + +

    + Head over to the{" "} + + Table Editor + {" "} + for your Supabase project to create a table and insert some example + data. If you're stuck for creativity, you can copy and paste the + following into the{" "} + + SQL Editor + {" "} + and click RUN! +

    + +
    + + +

    + To create a Supabase client and query data from an Async Server + Component, create a new page.tsx file at{" "} + + /app/notes/page.tsx + {" "} + and add the following. +

    + +

    Alternatively, you can use a Client Component.

    + +
    + + +

    You're ready to launch your product to the world! 🚀

    +
    +
+ ); +} diff --git a/components/tutorial/sign-up-user-steps.tsx b/components/tutorial/sign-up-user-steps.tsx new file mode 100644 index 0000000..c00fb66 --- /dev/null +++ b/components/tutorial/sign-up-user-steps.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import { TutorialStep } from "./tutorial-step"; +import { ArrowUpRight } from "lucide-react"; + +export default function SignUpUserSteps() { + return ( +
    + {process.env.VERCEL_ENV === "preview" || + process.env.VERCEL_ENV === "production" ? ( + +

    It looks like this App is hosted on Vercel.

    +

    + This particular deployment is + + "{process.env.VERCEL_ENV}" + {" "} + on + + https://{process.env.VERCEL_URL} + + . +

    +

    + You will need to{" "} + + update your Supabase project + {" "} + with redirect URLs based on your Vercel deployment URLs. +

    +
      +
    • + -{" "} + + http://localhost:3000/** + +
    • +
    • + -{" "} + + {`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}/**`} + +
    • +
    • + -{" "} + + {`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL?.replace(".vercel.app", "")}-*-[vercel-team-url].vercel.app/**`} + {" "} + (Vercel Team URL can be found in{" "} + + Vercel Team settings + + ) +
    • +
    + + Redirect URLs Docs + +
    + ) : null} + +

    + Head over to the{" "} + + Sign up + {" "} + page and sign up your first user. It's okay if this is just you for + now. Your awesome idea will have plenty of users later! +

    +
    +
+ ); +} diff --git a/components/tutorial/tutorial-step.tsx b/components/tutorial/tutorial-step.tsx new file mode 100644 index 0000000..0ab9cd4 --- /dev/null +++ b/components/tutorial/tutorial-step.tsx @@ -0,0 +1,30 @@ +import { Checkbox } from "../ui/checkbox"; + +export function TutorialStep({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
  • + + +
  • + ); +} diff --git a/components/typography/inline-code.tsx b/components/typography/inline-code.tsx new file mode 100644 index 0000000..288f9e3 --- /dev/null +++ b/components/typography/inline-code.tsx @@ -0,0 +1,7 @@ +export function TypographyInlineCode() { + return ( + + @radix-ui/react-alert-dialog + + ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..d3d5d60 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
    + ); +} + +export { Badge, badgeVariants }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..57c9fe4 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/components/ui/checkbox.tsx b/components/ui/checkbox.tsx new file mode 100644 index 0000000..5985e3c --- /dev/null +++ b/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..3a0c7fe --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -0,0 +1,200 @@ +"use client"; + +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..9d631e7 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..84f8b0c --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client"; + +import * as React from "react"; +import * as LabelPrimitive from "@radix-ui/react-label"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..53428f8 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,20 @@ +import { type NextRequest } from "next/server"; +import { updateSession } from "@/utils/supabase/middleware"; + +export async function middleware(request: NextRequest) { + return await updateSession(request); +} + +export const config = { + matcher: [ + /* + * Match all request paths except: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * - images - .svg, .png, .jpg, .jpeg, .gif, .webp + * Feel free to modify this pattern to include more paths. + */ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; diff --git a/next.config.js b/next.config.js new file mode 100644 index 0000000..658404a --- /dev/null +++ b/next.config.js @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +module.exports = nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..6f02c89 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@radix-ui/react-checkbox": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@supabase/ssr": "latest", + "@supabase/supabase-js": "latest", + "autoprefixer": "10.4.20", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "geist": "^1.2.1", + "lucide-react": "^0.456.0", + "next": "latest", + "next-themes": "^0.4.3", + "prettier": "^3.3.3", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "22.9.0", + "@types/react": "^18.3.12", + "@types/react-dom": "18.3.1", + "postcss": "8.4.49", + "tailwind-merge": "^2.5.2", + "tailwindcss": "3.4.14", + "tailwindcss-animate": "^1.0.7", + "typescript": "5.6.3" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..41668a3 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,80 @@ +import type { Config } from "tailwindcss"; + +const config = { + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + ], + prefix: "", + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} satisfies Config; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e06a445 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/utils/cn.ts b/utils/cn.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/utils/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/utils/supabase/check-env-vars.ts b/utils/supabase/check-env-vars.ts new file mode 100644 index 0000000..7180f45 --- /dev/null +++ b/utils/supabase/check-env-vars.ts @@ -0,0 +1,6 @@ +// This check can be removed +// it is just for tutorial purposes + +export const hasEnvVars = + process.env.NEXT_PUBLIC_SUPABASE_URL && + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; diff --git a/utils/supabase/client.ts b/utils/supabase/client.ts new file mode 100644 index 0000000..e2660d0 --- /dev/null +++ b/utils/supabase/client.ts @@ -0,0 +1,7 @@ +import { createBrowserClient } from "@supabase/ssr"; + +export const createClient = () => + createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + ); diff --git a/utils/supabase/middleware.ts b/utils/supabase/middleware.ts new file mode 100644 index 0000000..8619ec0 --- /dev/null +++ b/utils/supabase/middleware.ts @@ -0,0 +1,62 @@ +import { createServerClient } from "@supabase/ssr"; +import { type NextRequest, NextResponse } from "next/server"; + +export const updateSession = async (request: NextRequest) => { + // This `try/catch` block is only here for the interactive tutorial. + // Feel free to remove once you have Supabase connected. + try { + // Create an unmodified response + let response = NextResponse.next({ + request: { + headers: request.headers, + }, + }); + + 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 }) => + request.cookies.set(name, value), + ); + response = NextResponse.next({ + request, + }); + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options), + ); + }, + }, + }, + ); + + // This will refresh session if expired - required for Server Components + // https://supabase.com/docs/guides/auth/server-side/nextjs + const user = await supabase.auth.getUser(); + + // protected routes + if (request.nextUrl.pathname.startsWith("/protected") && user.error) { + return NextResponse.redirect(new URL("/sign-in", request.url)); + } + + if (request.nextUrl.pathname === "/" && !user.error) { + return NextResponse.redirect(new URL("/protected", request.url)); + } + + return response; + } catch (e) { + // If you are here, a Supabase client could not be created! + // This is likely because you have not set up environment variables. + // Check out http://localhost:3000 for Next Steps. + return NextResponse.next({ + request: { + headers: request.headers, + }, + }); + } +}; diff --git a/utils/supabase/server.ts b/utils/supabase/server.ts new file mode 100644 index 0000000..2c00bbc --- /dev/null +++ b/utils/supabase/server.ts @@ -0,0 +1,29 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; + +export const createClient = async () => { + const cookieStore = await cookies(); + + 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 (error) { + // The `set` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, + }, + }, + ); +}; diff --git a/utils/utils.ts b/utils/utils.ts new file mode 100644 index 0000000..c9fbbe8 --- /dev/null +++ b/utils/utils.ts @@ -0,0 +1,16 @@ +import { redirect } from "next/navigation"; + +/** + * Redirects to a specified path with an encoded message as a query parameter. + * @param {('error' | 'success')} type - The type of message, either 'error' or 'success'. + * @param {string} path - The path to redirect to. + * @param {string} message - The message to be encoded and added as a query parameter. + * @returns {never} This function doesn't return as it triggers a redirect. + */ +export function encodedRedirect( + type: "error" | "success", + path: string, + message: string, +) { + return redirect(`${path}?${type}=${encodeURIComponent(message)}`); +}