メインコンテンツまでスキップ

クイックスタート:RedwoodJS

イントロ

他のフレームワークのクイックスタート例との一貫性を保つために、RedwoodJSを通常とは少し違った方法で構築します。

Prismaを使ってSupabaseのPostgresデータベースに接続しません。また、Redwoodアプリで一般的に行われるようなPrismaのマイグレーション使用しません。 その代わりに、Supabaseクライアントに頼ってwebサイドで作業の一部を行い、apiサイドで再びクライアントを使ってデータの取得をします。

つまり、yarn rw prisma migrateコマンドの実行を控えます。また、Prismaがデータベースをリセットしないように、デプロイ時のビルド・コマンドを再確認する必要があります。

note

TLDR; Prismaは現在、スキーマ間の外部キーをサポートしていません。そのため、Supabaseのpublicスキーマがauth.usersを参照しているため、PrismaでスキーマをIntrospectすると失敗します。

この例では、SupabaseとRedwoodJSを使って、シンプルなユーザー管理アプリを(ゼロから)構築する手順を紹介します。内容は以下のとおりです。

  • Supabase Database:ユーザーデータを保存するためのPostgresデータベースです。
  • Supabase Auth:ユーザーはマジックリンクでサインインできます(パスワードは不要、メールのみ)。
  • Supabase Storage:ユーザーは写真をアップロードできます。
  • インスタントAPI:データベースのテーブルを作成すると、APIが自動的に生成されます。
  • 行単位セキュリティー:データは保護されており、個人が自分のデータにしかアクセスできないようになっています。

このガイドの最後には、ユーザーがログインして基本的なプロフィール情報を更新できるアプリが完成します。

Supabase User Management example

note

注:RedwoodJSアプリの場合、ポートは8910になります。

GitHub

どこかで行き詰ったら、このリポジトリーを見てみましょう。

RedwoodJSについて

Redwoodのアプリケーションは、フロントエンドとバックエンドの2つの部分に分かれています。これは1つのモノレポの中の2つのノード・プロジェクトとして表現されます。

フロントエンドのプロジェクトはweb、バックエンドのプロジェクトはapiと呼ばれています。 わかりやすくするために、これらを「サイド」と呼ぶことにします。 つまり、「webサイド」と「apiサイド」です。 webサイドのコードは最終的にユーザーのブラウザー上で実行されます。 apiサイドのコードはどこかのサーバー上で実行されるため、これらは別々のプロジェクトとなります。

note

重要:このガイドで「API」としたときはSupabase APIを指します。「apiサイド」としたときはRedwoodJSでのapiサイドを意味します。

apiサイドは、GraphQL APIの実装です。ビジネス・ロジックは、独自の内部APIを表す「サービス」に整理されており、外部のGraphQLリクエストからも、他の内部サービスからも呼びだすことができます。

webサイドはReactで構築されています。Redwoodのルーターは、URLパスをReactの「ページ」コンポーネントへ簡単にマッピングできます(そして、各ルートでアプリを自動的にコード分割します)。 ページには、コンテンツを包む「レイアウト」コンポーネントが含まれます。また、「セル」や通常のReactコンポーネントも含まれます。 セルは、データを取得して表示するコンポーネントのライフサイクルを宣言的に管理できます。

note

他のフレームワークのクイックスタート例との一貫性を保つために、RedwoodJSを通常とは少し違った方法で構築します。 Prismaを使ってSupabase Postgresデータベースに接続しません。また、Redwoodアプリで一般的に行われるようなPrismaのマイグレーション使用しません。 その代わりに、Supabaseクライアントに頼ってwebサイドで作業の一部を行い、apiサイドで再びクライアントを使ってデータの取得します。

プロジェクトのセットアップ

ビルドを開始する前に、データベースとAPIをセットアップします。Supabaseで新しいプロジェクトを立ち上げ、データベース内に「スキーマ」を作成するだけです。

note

Supabaseを設定する際には、プロジェクトのダッシュボードで操作することになります。しかし、RedwoodJSのセットアップ手順のほとんどは、Redwood CLIで対話的に、ルート、ページ、コンポーネントなどを生成します。 そのため、ターミナルを用意して、プロジェクトのディレクトリーで準備しておくようにしてください。

プロジェクトの作成

  1. app.supabase.comにアクセスします。
  2. 「New Project」をクリックします。
  3. プロジェクトの詳細を入力します。
  4. 新しいデータベースが起動するのを待ちます。

データベース・スキーマの設定

これからデータベース・スキーマを設定します。SQLエディターの「User Management Starter」クイック・スターターを使用するか、下記のSQLをコピー/ペーストして自分で実行できます。

1. 「SQL」セクションに移動します。
2. 「User Management Starter」をクリックします。
3. 「Run」をクリックします。

Prismaとマイグレーションに関する重要な注意事項

note

このクイックスタートでは、データベーススキーマの管理、シード、マイグレーションの実行にPrismaを使用していません。そのため、典型的なRedwood CLIコマンドのいくつかを実行する際には、特に注意する必要があります。

APIキーの取得

データベースのテーブルをいくつか作成したので、自動生成されたAPIを使ってデータを挿入する準備ができました。 API設定からURLとanonservice_roleJWT_SECRETキーを取得する必要があります。

1. 「Settings」セクションに移動します。
2. サイドバーの「API」をリックします。
3. そのページでAPI URLを探します。
4. 「anon」と「JWT Secret」キーを探します。

アプリの構築

それでは、RedwoodJSアプリを一から作ってみましょう。

note

RedwoodJSには、Node.js (>=14.x <=16.x)とYarn (>=1.15)が必要です。

RedwoodJSは、webサイドとapiサイドをワークスペースでパッケージ管理するためYarnに依存しているので、Yarnがインストールされていることを確認してください。

RedwoodJSアプリの初期化

Redwoodアプリの作成コマンドを使って、supabase-redwoodjsというアプリを初期化します。

yarn create redwood-app supabase-redwoodjs
cd supabase-redwoodjs

アプリが起動している間は、次のように表示されます。

✔ Creating Redwood app
✔ Checking node and yarn compatibility
✔ Creating directory 'supabase-redwoodjs'
✔ Installing packages
✔ Running 'yarn install'... (This could take a while)
✔ Convert TypeScript files to JavaScript
✔ Generating types

Thanks for trying out Redwood!

そして、唯一追加する必要がある依存関係のsupbase-jssetup authコマンドでインストールしましょう。

yarn redwood setup auth supabase

プロンプトが表示されます。

Overwrite existing /api/src/lib/auth.[jt]s? :::

yesと応えると、アプリにSupabaseクライアントが設定され、Supabaseの認証に使用するフックも提供されます。

✔ Generating auth lib...
✔ Successfully wrote file `./api/src/lib/auth.js`
✔ Adding auth config to web...
✔ Adding auth config to GraphQL API...
✔ Adding required web packages...
✔ Installing packages...
✔ One more thing...

You will need to add your Supabase URL (SUPABASE_URL), public API KEY,
and JWT SECRET (SUPABASE_KEY, and SUPABASE_JWT_SECRET) to your .env file.

次に、環境変数を.envに保存します。 必要なのは、API URLと、先ほどコピーしたanonjwt_secretキーが必要です。

.env
SUPABASE_URL=YOUR_SUPABASE_URL
SUPABASE_KEY=YOUR_SUPABASE_ANON_KEY
SUPABASE_JWT_SECRET=YOUR_SUPABASE_JWT_SECRET

そして最後に、webサイドの環境変数にだけredwood.tomlに保存する必要があります。

redwood.toml
[web]
title = "Supabase Redwood Quickstart"
port = 8910
apiProxyPath = "/.redwood/functions"
includeEnvironmentVariables = ["SUPABASE_URL", "SUPABASE_KEY"]
[api]
port = 8911
[browser]
open = true

これらの変数は、ブラウザー上で公開されますが、それは全く問題ありません。 これらの変数は、データベースで行単位セキュリティを有効にしているため、WebアプリがパブリックなanonキーでSupabaseクライアントを初期化するために使用されます。

web/src/App.jsでSupabaseクライアントの設定に使用されているのがわかります。

web/src/App.js
// ... Redwood imports
import { AuthProvider } from '@redwoodjs/auth'
import { createClient } from '@supabase/supabase-js'

// ...

const supabaseClient = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_KEY)

const App = () => (
<FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<AuthProvider client={supabaseClient} type="supabase">
<RedwoodApolloProvider>
<Routes />
</RedwoodApolloProvider>
</AuthProvider>
</RedwoodProvider>
</FatalErrorBoundary>
)

export default App

また、オプションとして、アプリの外観を整えるためにCSSファイルweb/src/index.cssを更新します。 こちらでファイルの詳細な内容を確認できます。

RedwoodJSを起動して最初のページを作る

アプリを起動して、現時点でのセットアップをテストしてみましょう。

yarn rw dev
note

注:RedwoodのCLIコマンドを実行するためにyarn rwのようにredwoodrwというエイリアスを使うこともできます。

「Welcome to RedwoodJS」というページと、まだページがないというメッセージが表示されるはずです。

それでは、「Home」ページを作ってみましょう。

yarn rw generate page home /

✔ Generating page files...
✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.stories.js`
✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.test.js`
✔ Successfully wrote file `./web/src/pages/HomePage/HomePage.js`
✔ Updating routes file...
✔ Generating types ...
note

注:ここではスラッシュの/が重要で、ルート・レベルのルートを作ります。

必要であれば、devサーバーを停止しても構いません。変更内容を確認するには、もう一度yarn rw devを実行してください。

web/src/Routes.jsHomeページのルートができるはずです。

web/src/Routes.js
import { Router, Route } from '@redwoodjs/router'

const Routes = () => {
return (
<Router>
<Route path="/" page={HomePage} name="home" />
<Route notfound page={NotFoundPage} />
</Router>
)
}

export default Routes

ログイン・コンポーネントの設定

ログインとサインアップを管理するRedwoodコンポーネントを設定しましょう。ここではマジック・リンクを使い、ユーザーがパスワードを使わずにメールでサインインできるようにします。

yarn rw g component auth

✔ Generating component files...
✔ Successfully wrote file `./web/src/components/Auth/Auth.test.js`
✔ Successfully wrote file `./web/src/components/Auth/Auth.stories.js`
✔ Successfully wrote file `./web/src/components/Auth/Auth.js`

それでは、Auth.jsコンポーネントをアップデートして次の内容を含むようにします。

/web/src/components/Auth/Auth.js
import { useState } from 'react'
import { useAuth } from '@redwoodjs/auth'

const Auth = () => {
const { logIn } = useAuth()
const [loading, setLoading] = useState(false)
const [email, setEmail] = useState('')

const handleLogin = async (email) => {
try {
setLoading(true)
const { error } = await logIn({ email })
if (error) throw error
alert('Check your email for the login link!')
} catch (error) {
alert(error.error_description || error.message)
} finally {
setLoading(false)
}
}

return (
<div className="row flex flex-center">
<div className="col-6 form-widget">
<h1 className="header">Supabase + RedwoodJS</h1>
<p className="description">Sign in via magic link with your email below</p>
<div>
<input
className="inputField"
type="email"
placeholder="Your email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<button
onClick={(e) => {
e.preventDefault()
handleLogin(email)
}}
className={'button block'}
disabled={loading}
>
{loading ? <span>Loading</span> : <span>Send magic link</span>}
</button>
</div>
</div>
</div>
)
}

export default Auth

アカウント・コンポーネントをセットアップ

ユーザーがサインインした後、プロフィールの詳細を編集したり、アカウントを管理できるようにします。

そのための新しいコンポーネント、Account.jsを作りましょう。

yarn rw g component account

✔ Generating component files...
✔ Successfully wrote file `./web/src/components/Account/Account.test.js`
✔ Successfully wrote file `./web/src/components/Account/Account.stories.js`
✔ Successfully wrote file `./web/src/components/Account/Account.js`

そして、このファイルを更新して、次のようにします。

web/src/components/Account/Account.js
import { useState, useEffect } from 'react'
import { useAuth } from '@redwoodjs/auth'

const Account = () => {
const { client: supabase, currentUser, logOut } = useAuth()
const [loading, setLoading] = useState(true)
const [username, setUsername] = useState(null)
const [website, setWebsite] = useState(null)
const [avatar_url, setAvatarUrl] = useState(null)

useEffect(() => {
getProfile()
}, [supabase.auth.session])

async function getProfile() {
try {
setLoading(true)
const user = supabase.auth.user()

let { data, error, status } = await supabase
.from('profiles')
.select(`username, website, avatar_url`)
.eq('id', user.id)
.single()

if (error && status !== 406) {
throw error
}

if (data) {
setUsername(data.username)
setWebsite(data.website)
setAvatarUrl(data.avatar_url)
}
} catch (error) {
alert(error.message)
} finally {
setLoading(false)
}
}

async function updateProfile({ username, website, avatar_url }) {
try {
setLoading(true)
const user = supabase.auth.user()

const updates = {
id: user.id,
username,
website,
avatar_url,
updated_at: new Date(),
}

let { error } = await supabase.from('profiles').upsert(updates, {
returning: 'minimal', // Don't return the value after inserting
})

if (error) {
throw error
}

alert('Updated profile!')
} catch (error) {
alert(error.message)
} finally {
setLoading(false)
}
}

return (
<div className="row flex flex-center">
<div className="col-6 form-widget">
<h1 className="header">Supabase + RedwoodJS</h1>
<p className="description">Your profile</p>
<div className="form-widget">
<div>
<label htmlFor="email">Email</label>
<input id="email" type="text" value={currentUser.email} disabled />
</div>
<div>
<label htmlFor="username">Name</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="website"
value={website || ''}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>

<div>
<button
className="button block primary"
onClick={() => updateProfile({ username, website, avatar_url })}
disabled={loading}
>
{loading ? 'Loading ...' : 'Update'}
</button>
</div>

<div>
<button className="button block" onClick={() => logOut()}>
Sign Out
</button>
</div>
</div>
</div>
</div>
)
}

export default Account
note

注:クイックスタートではuseAuth()を使っているのを何度か目にします。RedwoodのuseAuthフックは、logIn、logOut、currentUserへのアクセス、およびsupabase認証クライアントへのアクセスに便利な方法を提供します。このフックを使って、supabaseクライアントのインスタンスを取得し、APIと対話することになります。

ホームページの更新

すべてのコンポーネントが揃いましたので、HomePageページを更新して、それらを使用しましょう。

web/src/pages/HomePage/HomePage.js
import { useAuth } from '@redwoodjs/auth'
import { MetaTags } from '@redwoodjs/web'

import Account from 'src/components/Account'
import Auth from 'src/components/Auth'

const HomePage = () => {
const { isAuthenticated } = useAuth()

return (
<>
<MetaTags title="Welcome" />
{!isAuthenticated ? <Auth /> : <Account />}
</>
)
}

export default HomePage

ここでは、ログインしていない場合はサインインのフォームを表示し、ログインしている場合はアカウントのプロフィールを表示します。

ローンチ

これが終わったら、ターミナルウィンドウで次を実行して、devサーバーを起動します。

yarn rw dev

そして、ブラウザでlocalhost:8910を開くと、完成したアプリが表示されます。

Supabase RedwoodJS

おまけ:プロフィール写真

Supabaseのプロジェクトには、写真や動画などの大容量ファイルを管理するためのストレージが用意されています。

アップロード・ウィジェットの作成

ユーザーがプロフィール写真をアップロードできるように、ユーザーのアバターを作成しましょう。まず、新しいコンポーネントを作成します。

yarn rw g component avatar
✔ Generating component files...
✔ Successfully wrote file `./web/src/components/Avatar/Avatar.test.js`
✔ Successfully wrote file `./web/src/components/Avatar/Avatar.stories.js`
✔ Successfully wrote file `./web/src/components/Avatar/Avatar.js`

アバター・コンポーネントを更新して、以下のウィジェットを追加します。

web/src/components/Avatar/Avatar.js
import { useEffect, useState } from 'react'
import { useAuth } from '@redwoodjs/auth'

const Avatar = ({ url, size, onUpload }) => {
const { client: supabase } = useAuth()

const [avatarUrl, setAvatarUrl] = useState(null)
const [uploading, setUploading] = useState(false)

useEffect(() => {
if (url) downloadImage(url)
}, [url])

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.message)
}
}

async function uploadAvatar(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 fileName = `${Math.random()}.${fileExt}`
const filePath = `${fileName}`

let { error: uploadError } = await supabase.storage.from('avatars').upload(filePath, file)

if (uploadError) {
throw uploadError
}

onUpload(filePath)
} catch (error) {
alert(error.message)
} finally {
setUploading(false)
}
}

return (
<div>
{avatarUrl ? (
<img
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>
)
}

export default Avatar

新しいウィジェットの追加

そして、このウィジェットをAccountコンポーネントに追加します。

web/src/components/Account/Account.js
// Import the new component
import Avatar from 'src/components/Avatar'

// ...

return (
<div className="form-widget">
{/* Add to the body */}
<Avatar
url={avatar_url}
size={150}
onUpload={(url) => {
setAvatarUrl(url)
updateProfile({ username, website, avatar_url: url })
}}
/>
{/* ... */}
</div>
)

次のステップ

この段階で、完全に機能するアプリケーションが完成しました。