はじめに
今回はNext.jsとSupabaseのAuthを利用してユーザーログイン機能を持ったTwitter風アプリを作成していきます。
このハンズオンで行うログイン機能を利用することで自分のアプリにも簡単にログイン認証を導入することが可能になります。
このハンズオンはReactをやったことがある人向けに作成しておりますので、Next.jsをやったことなかったり、Supabaseを触ったことない人でも学ぶことが可能です。
動画での解説
細かいところなどは動画でより詳しく解説していますので、合わせてご利用ください!
1. 環境構築
まずはNext.jsとTailwindCSSの環境を用意します
ここではnpmが使えることを全体に説明しますので、npmが使えない方はネットで調べていただいて導入を行ってください
❯ npx create-next-app@latest
✔ What is your project named? … next-twitter
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
$ cd next-twitter
$ npm i
$ npm run dev
localhost:3000を開いて以下の画面がでれば環境構築が完了です
next-twitter
をVSCodeで開きます
2. Authの設定
以下のページを参考にNext.jsのログインの初期設定を行います。
この内容に関しては公式ドキュメント通りに行っていきます。
ここではsupabaseのアカウントを作成している前提で進めていくので、初めての方はアカウント作成とプロジェクト作成を行ってください
左メニューからProject Settings
->API
を選択します。
Projiect URL
とProject API Keys
のanon
のキーを使って.env
を作成します
$ touch .env
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
次に必要なライブラリを追加します
$ npm install @supabase/supabase-js @supabase/ssr
ここからAuthを使うための設定をしていきます
$ mkdir -p src/utils/supabase
$ mkdir src/app/auth/confirm
$ touch src/utils/supabase/client.ts
$ touch src/utils/supabase/server.ts
$ touch src/middleware.ts
$ touch src/utils/supabase/midlleware.ts
$ touch src/app/auth/confirm/route.ts
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
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.
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: "", ...options });
} catch (error) {
// The `delete` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
}
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
get(name: string) {
return cookieStore.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
try {
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.
}
},
remove(name: string, options: CookieOptions) {
try {
cookieStore.set({ name, value: "", ...options });
} catch (error) {
// The `delete` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
}
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 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)$).*)",
],
};
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function updateSession(request: NextRequest) {
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: {
get(name: string) {
return request.cookies.get(name)?.value;
},
set(name: string, value: string, options: CookieOptions) {
request.cookies.set({
name,
value,
...options,
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
response.cookies.set({
name,
value,
...options,
});
},
remove(name: string, options: CookieOptions) {
request.cookies.set({
name,
value: "",
...options,
});
response = NextResponse.next({
request: {
headers: request.headers,
},
});
response.cookies.set({
name,
value: "",
...options,
});
},
},
}
);
await supabase.auth.getUser();
return response;
}
import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'
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 = searchParams.get('next') ?? '/'
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)
}
ここまでの内容はかなり難しいですが、公式ドキュメント通りに設定しております。
詳しく内容を理解する必要はいまのところないので先に進みましょう
3. ログイン画面
ログイン画面を作成します。
まずはログイン画面を作成します。
$ mkdir -p src/app/login
$ touch src/app/login/page.tsx
$ touch src/app/login/actions.ts
"use client";
import Link from "next/link";
import { login } from "./actions";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
return (
<div className="flex justify-center items-center h-screen bg-gray-100">
<form className="p-8 bg-white rounded-lg shadow-md">
<div className="mb-6">
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700"
>
Email:
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div className="mb-6">
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700"
>
Password:
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
/>
</div>
<div className="flex items-center justify-between mb-6">
<button
type="submit"
formAction={login}
className="w-full text-white bg-blue-500 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
>
Log in
</button>
</div>
<div className="flex items-center justify-between mb-6">
<button
type="button"
className="w-full text-white bg-green-500 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
onClick={() => router.push("/register")}
>
Sign up
</button>
</div>
<div className="text-center">
<Link href="/login/forget-password">
<button className="text-sm text-blue-600 hover:underline">
Forget password?
</button>
</Link>
</div>
</form>
</div>
);
}
"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("/");
}
localhost:3000/loginにアクセウするとログイン画面を表示できます
ここではログインボタンを押したときの挙動について解説します
<button
type="submit"
formAction={login}
className="w-full text-white bg-blue-500 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
>
Log in
</button>
ここではNext.jsのServer Actionsを利用しています。
form要素のactionに'use server'をつけた関数を渡すことで、JavaScriptでなくHTMLの機能だけを用いてサーバーにデータを送信することを可能にします
ボタンを押したときのactionではsignInWithPassword
にログイン情報を送ることで簡単にログイン認証することが可能です
const { error } = await supabase.auth.signInWithPassword(data);
しかし、いまはまだユーザーがないためログインに失敗します。
ログインに失敗すると
if (error) {
redirect("/error");
}
ここが実行されlocalhost:3000/error
に遷移します。
なのでページを用意します
$ mkdir src/app/error
$ touch src/app/error/page.tsx
export default function ErrorPage() {
return <p>Sorry, something went wrong</p>;
}
適当なメールアドレスとパスワードをいれてlogin
をクリックするとエラーページが表示されました
3. サインアップ画面
ではユーザーを作成できるようにしましょう
ここでは、アカウント作成とユーザー情報作成をします。
- メールアドレス
- パスワード
- 名前
をいれることで、ログインしたあとにユーザーを作成することで、Tweetのユーザー名をツイートに紐づけできるようにします
$ mkdir src/app/register
$ touch src/app/register/page.tsx
$ touch src/app/register/actions.ts
import { signup } from "./actions";
export default function RegisterUser() {
return (
<div className="flex justify-center items-center h-screen bg-gray-100">
<form className="p-8 bg-white rounded-lg shadow-md max-w-sm w-full">
<div className="mb-4">
<input
type="text"
name="name"
placeholder="名前"
className="w-full p-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="mb-4">
<input
type="email"
name="email"
placeholder="メールアドレス"
className="w-full p-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="mb-6">
<input
type="password"
name="password"
placeholder="パスワード"
className="w-full p-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<button
type="submit"
formAction={signup}
className="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
>
登録
</button>
</form>
</div>
);
}
"use server";
import { createClient } from "@/utils/supabase/server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
export async function signup(formdata: FormData) {
const supabase = createClient();
const email = formdata.get("email") as string;
const password = formdata.get("password") as string;
const { data, error } = await supabase.auth.signUp({
email,
password,
});
console.log(error);
if (error || !data?.user) {
redirect("/error");
}
const userData = await supabase.from("user").insert({
name: formdata.get("name") as string,
user_id: data.user.id,
});
if (userData.error) {
redirect("/error");
}
revalidatePath("/", "layout");
redirect("/");
}
localhost:3000/registerにアクセスすると以下が表示されます(signUpボタンからでも遷移できます)
ここでも先ほどと同じようにserver actionを利用しています
const { data, error } = await supabase.auth.signUp({
email,
password,
});
console.log(error);
if (error || !data?.user) {
redirect("/error");
}
const userData = await supabase.from("user").insert({
name: formdata.get("name") as string,
user: data.user.id,
});
今回はauth.singUp
を使っています。この関数が呼び出されるとメール宛に確認メールが届くので、リンクをクリックすることでアカウント作成が行われます
このAuthの情報にはユーザー名などの情報を含められないため、ユーザーテーブルにAuthのIDと名前をインサートしています
このままではユーザーテーブルがないのでSupabase画面で作成していきます
左メニューからTable Editor
→create a new table
を選択して以下のテーブルを作成します
まずはRLSをオフにします
カラムは以下を作成します
nameとuser_idは歯車マークをクリックしてisNullable
のチェックを外してください
次にuser_idと作成したアカウントのIDを紐づけます
user_id
のリンクマークをクリックして以下のように設定します
設定したら保存をします
ここまでできたら実際にログインができるかをチェックします
※ TwitterのUIはまだ作成していないのでlocalhost:3000にリダイレクトすれば大丈夫です
4. パスワード再設定
パスワード再設定画面を作ります
$ mkdir src/app/login/forget-password
$ mkdir src/app/login/reset-password
$ touch src/app/login/forget-password/page.tsx
$ touch src/app/login/reset-password/page.tsx
"use client";
import { supabase } from "@/utils/supabase/client";
import { AuthError } from "@supabase/supabase-js";
import { useState } from "react";
export default function ForgetPassword() {
const [email, setEmail] = useState<string>("");
const [isSend, setIsSend] = useState<boolean>(false);
const [error, setError] = useState<AuthError | null>(null);
const handleSubmitEmail = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
try {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: "http://localhost:3000/login/reset-password",
});
if (error) {
setError(error);
throw error;
}
setIsSend(true);
} catch (error) {
console.error(error);
}
};
const handleSetEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
};
if (error) {
return <p className="text-red-500">エラーが発生しました。</p>;
}
if (isSend) {
return (
<p className="text-green-500">
メールを送信しました。メールボックスをご確認ください。
</p>
);
}
return (
<div className="flex justify-center items-center h-screen bg-gray-100">
<div className="w-full max-w-xs">
<p className="mb-4 text-lg text-gray-700">
登録されているメールアドレスを入力してください
</p>
<form
onSubmit={handleSubmitEmail}
className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4"
>
<div className="mb-4">
<input
className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
id="email"
type="email"
value={email}
onChange={handleSetEmail}
placeholder="メールアドレス"
/>
</div>
<div className="flex items-center justify-between">
<button
className="bg-blue-500 w-full hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
type="submit"
>
送信
</button>
</div>
</form>
</div>
</div>
);
}
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: "http://localhost:3000/login/reset-password",
});
この箇所でリセット用のメールを送信しています。メールのリンクを踏むとreset-password
にリダイレクトするように設定しました
localhost:3000/login/forget-passwordにアクセスします
次にメールのリンクをふんだらリダイレクトする画面を作ります
"use client";
import { supabase } from "@/utils/supabase/client";
import { AuthError } from "@supabase/supabase-js";
import { useState } from "react";
export default function ResetPassword() {
const [password, setPassword] = useState<string>("");
const [isSend, setIsSend] = useState<boolean>(false);
const [error, setError] = useState<AuthError | null>(null);
const handleSubmitPassword = async (
event: React.FormEvent<HTMLFormElement>
) => {
event.preventDefault();
try {
const { error } = await supabase.auth.updateUser({ password: password });
if (error) {
setError(error);
throw error;
}
setIsSend(true);
} catch (error) {
console.error(error);
}
};
const handleSetPassword = (event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
};
if (error) {
return (
<p className="text-red-500 text-center mt-4">
エラーが発生しました。もう一度お試しください。
</p>
);
}
if (isSend) {
return (
<p className="text-green-500 text-center mt-4">
パスワードが更新されました。
</p>
);
}
return (
<div className="flex justify-center items-center h-screen bg-gray-100">
<div className="w-full max-w-xs p-8 bg-white rounded-lg shadow-md">
<p className="text-lg text-gray-700 mb-4">
新しいパスワードを入力してください
</p>
<form onSubmit={handleSubmitPassword} className="space-y-4">
<input
className="w-full p-2 text-gray-700 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
type="password"
value={password}
onChange={handleSetPassword}
placeholder="パスワード"
/>
<button
type="submit"
className="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
>
送信
</button>
</form>
</div>
</div>
);
}
localhost:3000/login/reset-passwordにアクセスします
では実際にパスワードの再設定をやってみます
4. Twitterクローンの作成
ここまででアカウント作成とアカウントに紐づくユーザー情報を作成できたのでこの情報を利用してTwitterクローンを作成します
まずはアイコンを表示させるためのライブラリを追加します
$ npm i boring-avatars
次に初期のCSSをなくします
@tailwind base;
@tailwind components;
@tailwind utilities;
ツイートをSupabaseで管理するためテーブルを作成します(RLSはオフにしてください)
user_idはリンクマークから外部キーを設定します
"use client";
import { useEffect, useState } from "react";
import Avatar from "boring-avatars";
import { supabase } from "@/utils/supabase/client";
import { useRouter } from "next/navigation";
type Tweet = {
id: number;
username: string;
content: string;
};
export default function Home() {
const [userId, setUserId] = useState<string | null>(null);
const [userName, setUserName] = useState<string | null>(null);
const [tweet, setTweet] = useState("");
const [tweetList, setTweetList] = useState<Tweet[]>([]);
const router = useRouter();
useEffect(() => {
const getUser = async () => {
const userData = await supabase.auth.getUser();
if (!userData.data.user) {
router.push("/login");
return;
}
const usernameResponse = await supabase
.from("user")
.select("name, id")
.eq("user_id", userData.data.user.id)
.single();
if (usernameResponse.data) {
setUserId(usernameResponse.data.id);
setUserName(usernameResponse.data.name);
}
};
getUser();
getTweet();
}, []);
const getTweet = async () => {
const tweetsResponse = (await supabase
.from("tweet")
.select("id, content, user:user_id (name)")) as {
data: { id: number; content: string; user: { name: string } }[];
};
if (tweetsResponse.data) {
const tweetsData = tweetsResponse.data.map((tweet) => {
return {
id: tweet.id,
username: tweet.user.name,
content: tweet.content,
};
});
setTweetList(tweetsData);
}
};
const handleAddTweet = async () => {
console.log(userId);
const { error } = await supabase
.from("tweet")
.insert([{ content: tweet, user_id: userId }]);
if (error) {
console.error(error);
} else {
setTweet("");
await getTweet();
}
};
const handleLogout = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
console.error(error);
} else {
router.push("/login");
}
};
if (!userId) {
return <div>Loading...</div>;
}
return (
<div className="w-full max-w-[600px] mx-auto p-4 bg-white shadow">
<h1 className="text-2xl font-bold mb-4">Twitter Timeline</h1>
<div className="mb-4 flex space-x-4">
<Avatar size={50} name={userName || "User"} variant="beam" />
<input
type="text"
className="flex-1 p-2 border border-gray-300 rounded-full"
placeholder="What is happening?!"
value={tweet}
onChange={(e) => setTweet(e.target.value)}
/>
</div>
<div className="text-right mb-4">
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-full"
onClick={handleAddTweet}
>
Post
</button>
</div>
<div>
{tweetList.map(({ id, username, content }) => (
<div key={id} className="mb-4 p-4 border-b border-gray-200">
<div className="flex gap-4">
<Avatar size={50} name={username} variant="beam" />
<div className="flex-1">
<p className="font-bold">{username}</p>
<p>{content}</p>
</div>
</div>
</div>
))}
</div>
<div className="text-center mt-4">
<button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-full"
onClick={handleLogout}
>
ログアウト
</button>
</div>
</div>
);
}
詳しくコードを説明します
ログインしたユーザーだけが利用できるように最初に認可を行っています
const getUser = async () => {
const userData = await supabase.auth.getUser();
if (!userData.data.user) {
router.push("/login");
return;
}
もしログインしていない場合はログイン画面にリダイレクトされます
画面ロード時に認可を行い、ユーザーの情報とすべてのツイートをロードしています
useEffect(() => {
const getUser = async () => {
const userData = await supabase.auth.getUser();
if (!userData.data.user) {
router.push("/login");
return;
}
const usernameResponse = await supabase
.from("user")
.select("name, id")
.eq("user_id", userData.data.user.id)
.single();
if (usernameResponse.data) {
setUserId(usernameResponse.data.id);
setUserName(usernameResponse.data.name);
}
};
getUser();
getTweet();
}, []);
ツイートをしたらsupabaseにインサートをしてデータの再取得をしています
const handleAddTweet = async () => {
console.log(userId);
const { error } = await supabase
.from("tweet")
.insert([{ content: tweet, user_id: userId }]);
if (error) {
console.error(error);
} else {
setTweet("");
await getTweet();
}
};
最後にログアウトはAuthの関数を呼び出しています
const handleLogout = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
console.error(error);
} else {
router.push("/login");
}
};
localhost:3000を開いて確認します
ここまでできればハンズオンは終了です!
おわりに
今回はSupabaseのAuthを活用してログイン機能を作り、Twitterクローンを作成しました。ここまでの内容を自分のアプリに適応すれば簡単にログイン機能を追加することが可能です。
ここまではなかなか複雑なこともありますので動画もぜひ活用ください!
ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。
また明日の記事でお会いしましょう!
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
実践的なカリキュラムで、あなたのエンジニアとしてのキャリアを最短で飛躍させましょう!
実践重視:即戦力を育てるアウトプット中心のプログラム。
モダンなスキル : Reactを中心としたモダンな技術を学べる。
キャリアアップ:スキルアップだけでなく、キャリアパスのサポートも充実。
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください!
▼▼▼