原文:Full Stack Development with Next.js and Supabase – The Complete Guide,作者:Nader Dabit

Supabase 是一个Firebase的开源替代品,让你在不到两分钟的时间内创建一个实时后台。

在过去的几个月里,Supabase 持续被我周围的开发人员宣传和采用。和我交流过的很多人都喜欢它利用 SQL 风格的数据库这一事实,他们也喜欢它是开源的。

当你创建一个项目时,Supabase会自动给你一个Postgres SQL数据库、用户认证和API。从那里你可以很容易地实现额外的功能,如实时订阅和文件存储。

在本指南中,你将学习如何构建一个全栈应用程序,实现大多数应用程序需要的核心功能--如路由、数据库、API、认证、授权、实时数据和细粒度访问控制。我们将使用一个现代堆栈,包括React, Next.js, 和 TailwindCSS.

我试图把我自己在使用Supabase的过程中所学到的东西提炼成一个尽可能简短的指南,这样你也可以开始用这个框架构建全栈应用。

我们将建立的应用程序是一个多用户博客应用程序,其中包含了你在许多现代应用程序中看到的所有类型的功能。这将使我们超越基本的CRUD,实现文件存储以及授权和细粒度的访问控制等功能。

你可以在这里找到我们将要建立的应用程序的代码。

通过学习如何将所有这些功能结合在一起,你应该能够利用你在这里学到的东西,建立你自己的想法。了解基本结构本身,你就可以在将来带着这些知识,以你认为合适的方式使用它。

Supabase概述

如何构建全栈式应用程序

我对全栈式无服务器框架非常着迷,因为它们为希望构建完整应用的开发者提供了大量的支持和敏捷性。

Supabase将强大的后端服务与易于使用的客户端库和SDK结合起来,提供了一个端到端的解决方案。

这种组合使你不仅可以在后端建立起必要的个别功能和服务,而且可以通过利用同一团队维护的客户端库,在前端轻松地将它们整合在一起。

由于Supabase是开源的,你可以选择自我托管或将你的后端部署为托管服务。而且,正如你所看到的,我们很容易就能使用它的免费服务,不需要信用卡。

为什么使用Supabase

我曾在AWS领导前端Web和移动开发者倡导团队,并写了一本关于构建这些类型的应用程序的书。所以我在这个领域积累了相当多的经验。

我认为Supabase带来了一些非常强大的功能,当我开始使用它进行构建时,这些功能立即让我感到震惊。

数据访问方式

我过去使用的一些工具和框架的最大限制之一是缺乏查询功能。我非常喜欢Supabase,因为它是建立在Postgres之上的,它能够实现一套极其丰富的、开箱即用的高性能查询功能,而不需要编写任何额外的后端代码。

客户端SDK提供了易于使用的filtersmodifiers,以实现几乎无限的数据访问模式的组合。

因为数据库是SQL,关系型数据很容易配置和查询,而且客户端库把它作为第一等公民来考虑。

权限

当你写好“hello world”时,许多类型的框架和服务很快就会被淘汰。这是因为大多数真实世界的需要远远超出了你经常看到的这些工具所提供的基本CRUD功能。

一些框架和管理服务的问题是,它们创建的抽象没有足够的扩展性,无法轻松修改配置或定制业务逻辑。这些限制往往使其难以考虑到在现实世界中构建应用程序时出现的许多一次性的用例。

除了实现广泛的数据访问模式外,Supabase还使配置授权和精细的访问控制变得容易。这是因为它是简单的Postgres,使你能够直接从内置的SQL编辑器中实现任何你想要的row-level security policies(行级安全策略) 你可以直接从内置的SQL编辑器中获取你想要的信息(我们将在这里讨论这个问题)。

UI 组件

除了由构建其他Supabase工具的同一个团队维护的客户端库之外,他们还维护了一个UI 组件库 (beta)使你能够使用各种UI元素来启动和运行。

最强大的是Auth,它与你的Supabase项目集成,以快速启动一个用户认证流程(我将在本教程中使用它)。

多种认证方式

Supabase启用了以下所有类型的认证机制:

  1. 用户名和密码
  2. 电子邮件链接
  3. 谷歌
  4. Facebook
  5. Apple
  6. GitHub
  7. Twitter
  8. Azure
  9. GitLab
  10. Bitbucket

开源

它最大的优点之一是完全开源(是的,后端也是)。这意味着,你可以选择无服务器托管的方式,也可以自己托管。

这意味着,如果你愿意,你可以用Docker运行Supabase,托管自己的应用程序 在 AWS、GCP 或 Azure上。这将让你在使用Supabase时避免云服务商锁定问题。

如何开始使用Supabase

项目设置

为了开始,让我们首先创建Next.js应用程序。

npx create-next-app next-supabase

接下来,进入目录并使用NPM或Yarn安装我们的应用程序所需的依赖。

npm install @supabase/supabase-js @supabase/ui react-simplemde-editor easymde react-markdown uuid
npm install tailwindcss@latest @tailwindcss/typography postcss@latest autoprefixer@latest

接下来,创建必要的Tailwind配置文件:

npx tailwindcss init -p

现在更新tailwind.config.js,将Tailwind typography插件添加到插件数组中。我们将使用这个插件来为我们的博客设计标记。

plugins: [
  require('@tailwindcss/typography')
]

最后,将styles/globals.css中的样式替换为以下内容。

@tailwind base;
@tailwind components;
@tailwind utilities;

Supabase项目初始化

现在项目已经在本地创建,让我们来创建Supabase项目。

为此,请前往Supabase.io,点击Start Your Project。通过GitHub账号完成认证,然后在账户中提供给你的组织下创建一个新项目。

myaccountsorg

给项目一个NamePassword,然后点击Create new project

你的项目将需要大约2分钟的时间来创建。

如何在Supabase中创建一个数据库表

当你创建了你的项目,让我们继续为我们的应用程序创建表,以及我们需要的所有权限。要做到这一点,请点击左边菜单中的SQL链接。

Screen-Shot-2021-06-06-at-6.07.00-PM

在这个视图中,点击Query-1下的 Open queries,粘贴以下SQL查询,并点击 RUN

CREATE TABLE posts (
  id bigint generated by default as identity primary key,
  user_id uuid references auth.users not null,
  user_email text,
  title text,
  content text,
  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);

alter table posts enable row level security;

create policy "Individuals can create posts." on posts for
    insert with check (auth.uid() = user_id);

create policy "Individuals can update their own posts." on posts for
    update using (auth.uid() = user_id);

create policy "Individuals can delete their own posts." on posts for
    delete using (auth.uid() = user_id);

create policy "Posts are public." on posts for
    select using (true);

这将创建我们将在应用程序中使用的posts表。它还启用了一些行级(row)权限:

  • 所有用户都可以查询帖子
  • 只有已登录的用户可以创建帖子,他们的用户ID必须与传入参数的用户ID一致
  • 只有帖子的主人可以更新或删除它

现在,如果我们点击Table editor链接,我们应该看到我们的新表以适当的模式创建。

Screen-Shot-2021-06-06-at-6.11.49-PM

这这样!我们的后端已经准备好了,我们可以开始建立用户界面了。用户名+密码认证已经默认启用,所以我们现在需要做的就是在前端把一切都连接起来。

Next.js Supabase 配置

现在项目已经创建,我们需要一种方法让我们的Next.js应用程序知道我们刚刚为它创建的后端服务。

对我们来说,配置的最好方法是使用环境变量。Next.js允许通过在项目根部创建一个名为 .env.local 的文件来设置环境变量,并将它们存储在那里。

为了向浏览器暴露一个变量,你必须在变量前加上 NEXT_PUBLIC_

在项目的根部创建一个名为**.env.local** 的文件,并添加以下配置。

NEXT_PUBLIC_SUPABASE_URL=https://app-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-public-api-key

你可以在Supabase仪表板的设置中找到你的API URL和API密钥的值:

appurls

接下来,在项目的根部创建一个名为api.js的文件,并添加以下代码:

// api.js
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)

现在我们将能够导入supabase实例并在我们的应用程序中的任何地方使用它。

下面是使用Supabase JavaScript客户端与API互动的概况。

查询数据:

import { supabase } from '../path/to/api'

const { data, error } = await supabase
  .from('posts')
  .select()

在数据库中创建新条目:

const { data, error } = await supabase
  .from('posts')
  .insert([
    {
      title: "Hello World",
      content: "My first post",
      user_id: "some-user-id",
      user_email: "myemail@gmail.com"
    }
  ])

正如我前面提到的,filtersmodifiers使得实现各种数据访问模式和你的数据的选择集变得非常容易。

认证 – 注册:

const { user, session, error } = await supabase.auth.signUp({
  email: 'example@email.com',
  password: 'example-password',
})

认证 – 登录:

const { user, session, error } = await supabase.auth.signIn({
  email: 'example@email.com',
  password: 'example-password',
})

在我们的案例中,我们不会手工编写主要的认证逻辑,我们将使Supabase UI中的Auth组件。

如何构建应用程序

现在,让我们开始构建用户界面吧!

为了开始,让我们首先更新应用程序,实现一些基本的导航和布局风格。

我们还将配置一些逻辑,以检查用户是否已经登录,并在他们登录后显示一个创建新帖子的链接。

最后,我们将为任何`auth'事件实现一个监听器。当一个新的“Auth”事件发生时,我们将检查以确保当前有一个登录的用户,以便显示或隐藏 Create Post 的链接。

打开文件 _app.js,添加以下代码:

// pages/_app.js
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { supabase } from '../api'
import '../styles/globals.css'

function MyApp({ Component, pageProps }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    const { data: authListener } = supabase.auth.onAuthStateChange(
      async () => checkUser()
    )
    checkUser()
    return () => {
      authListener?.unsubscribe()
    };
  }, [])
  async function checkUser() {
    const user = supabase.auth.user()
    setUser(user)
  }
  return (
  <div>
    <nav className="p-6 border-b border-gray-300">
      <Link href="/">
        <span className="mr-6 cursor-pointer">Home</span>
      </Link>
      {
        user && (
          <Link href="/create-post">
            <span className="mr-6 cursor-pointer">Create Post</span>
          </Link>
        )
      }
      <Link href="/profile">
        <span className="mr-6 cursor-pointer">Profile</span>
      </Link>
    </nav>
    <div className="py-8 px-16">
      <Component {...pageProps} />
    </div>
  </div>
  )
}

export default MyApp

如何制作一个用户资料页

接下来,让我们创建profile页面。在页面目录中,创建一个名为profile.js的新文件,并添加以下代码:

// pages/profile.js
import { Auth, Typography, Button } from "@supabase/ui";
const { Text } = Typography
import { supabase } from '../api'

function Profile(props) {
    const { user } = Auth.useUser();
    if (user)
      return (
        <>
          <Text>Signed in: {user.email}</Text>
          <Button block onClick={() => props.supabaseClient.auth.signOut()}>
            Sign out
          </Button>
        </>
      );
    return props.children 
}

export default function AuthProfile() {
    return (
        <Auth.UserContextProvider supabaseClient={supabase}>
          <Profile supabaseClient={supabase}>
            <Auth supabaseClient={supabase} />
          </Profile>
        </Auth.UserContextProvider>
    )
}

简介页使用了Auth组件,源自Supabase UI library。 这组件将渲染出一个有“sign up” 和 “sign in” 表单(form)为 未认证 用户, 一个带有“sign out”按钮的基本用户资料为已认证用户。 它还将启用一个有随机数(magic)的登录链接。

如何创建新的帖子

接下来,让我们创建一个create-post页面。在pages目录下,创建一个名为create-post.js的页面,代码如下:

// pages/create-post.js
import { useState } from 'react'
import { v4 as uuid } from 'uuid'
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import "easymde/dist/easymde.min.css"
import { supabase } from '../api'

const SimpleMDE = dynamic(() => import('react-simplemde-editor'), { ssr: false })
const initialState = { title: '', content: '' }

function CreatePost() {
  const [post, setPost] = useState(initialState)
  const { title, content } = post
  const router = useRouter()
  function onChange(e) {
    setPost(() => ({ ...post, [e.target.name]: e.target.value }))
  }
  async function createNewPost() {
    if (!title || !content) return
    const user = supabase.auth.user()
    const id = uuid()
    post.id = id
    const { data } = await supabase
      .from('posts')
      .insert([
          { title, content, user_id: user.id, user_email: user.email }
      ])
      .single()
    router.push(`/posts/${data.id}`)
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6">Create new post</h1>
      <input
        onChange={onChange}
        name="title"
        placeholder="Title"
        value={post.title}
        className="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      /> 
      <SimpleMDE
        value={post.content}
        onChange={value => setPost({ ...post, content: value })}
      />
      <button
        type="button"
        className="mb-4 bg-green-600 text-white font-semibold px-8 py-2 rounded-lg"
        onClick={createNewPost}
      >Create Post</button>
    </div>
  )
}

export default CreatePost

这个组件渲染了一个Markdown编辑器,允许用户创建新的帖子。

createNewPost函数将使用supabase实例,使用本地表单状态创建新的帖子。

你可能注意到,我们没有传入任何消息头(headers)。这是因为如果用户已经登录,Supabase客户端库会自动将访问令牌包含在已登录用户的消息头(headers)中。

如何查看单个帖子

我们需要配置一个页面来查看单个帖子。

这个页面使用getStaticPaths来在构建时根据从API回来的帖子动态地创建页面。

我们还使用fallback标志来启用动态SSG页面生成的返回路径。

我们使用getStaticProps使帖子数据被获取,然后在构建时作为props传入页面。

pages目录下创建一个名为posts的新文件夹, 并在该文件夹中创建一个名为[id\].js 的文件。在pages/posts/[id\].js,添加以下代码。

// pages/posts/[id].js
import { useRouter } from 'next/router'
import ReactMarkdown from 'react-markdown'
import { supabase } from '../../api'

export default function Post({ post }) {
  const router = useRouter()
  if (router.isFallback) {
    return <div>Loading...</div>
  }
  return (
    <div>
      <h1 className="text-5xl mt-4 font-semibold tracking-wide">{post.title}</h1>
      <p className="text-sm font-light my-4">by {post.user_email}</p>
      <div className="mt-8">
        <ReactMarkdown className='prose' children={post.content} />
      </div>
    </div>
  )
}

export async function getStaticPaths() {
  const { data, error } = await supabase
    .from('posts')
    .select('id')
  const paths = data.map(post => ({ params: { id: JSON.stringify(post.id) }}))
  return {
    paths,
    fallback: true
  }
}

export async function getStaticProps ({ params }) {
  const { id } = params
  const { data } = await supabase
    .from('posts')
    .select()
    .filter('id', 'eq', id)
    .single()
  return {
    props: {
      post: data
    }
  }
}

如何查询和渲染帖子列表

接下来,让我们更新index.js,以获取并渲染一个帖子的列表:

// pages/index.js
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { supabase } from '../api'

export default function Home() {
  const [posts, setPosts] = useState([])
  const [loading, setLoading] = useState(true)
  useEffect(() => {
    fetchPosts()
  }, [])
  async function fetchPosts() {
    const { data, error } = await supabase
      .from('posts')
      .select()
    setPosts(data)
    setLoading(false)
  }
  if (loading) return <p className="text-2xl">Loading ...</p>
  if (!posts.length) return <p className="text-2xl">No posts.</p>
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">Posts</h1>
      {
        posts.map(post => (
          <Link key={post.id} href={`/posts/${post.id}`}>
            <div className="cursor-pointer border-b border-gray-300	mt-8 pb-4">
              <h2 className="text-xl font-semibold">{post.title}</h2>
              <p className="text-gray-500 mt-2">Author: {post.user_email}</p>
            </div>
          </Link>)
        )
      }
    </div>
  )
}

让我们来测试一下

现在我们的应用程序的所有部分都准备好了,所以让我们来试试。

要运行本地服务器,从你的终端运行dev命令。

npm run dev

当应用程序加载时,你应该看到以下画面:

SS1-1

要注册,请点击Profile并创建一个新账户。你应该在注册后收到一个电子邮件链接,以确认你的账户。

你也可以通过使用随机数(magic)的链接创建一个新账户。

当你登录后,你应该能够创建新的帖子:

SS2

回到主页,你应该能够看到你已经创建的帖子的列表,并能够点击帖子的链接来查看它。

SS3

如何编辑帖子

现在我们已经启动并运行了应用程序,让我们来学习如何编辑帖子。为了开始学习,让我们创建一个新的视图(view),它将只获取已登录用户创建的帖子。

为此,在项目的根目录创建一个名为my-posts.js的新文件,代码如下:

// pages/my-posts.js
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { supabase } from '../api'

export default function MyPosts() {
  const [posts, setPosts] = useState([])
  useEffect(() => {
    fetchPosts()
  }, [])

  async function fetchPosts() {
    const user = supabase.auth.user()
    const { data } = await supabase
      .from('posts')
      .select('*')
      .filter('user_id', 'eq', user.id)
    setPosts(data)
  }
  async function deletePost(id) {
    await supabase
      .from('posts')
      .delete()
      .match({ id })
    fetchPosts()
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">My Posts</h1>
      {
        posts.map((post, index) => (
          <div key={index} className="border-b border-gray-300	mt-8 pb-4">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p className="text-gray-500 mt-2 mb-2">Author: {post.user_email}</p>
            <Link href={`/edit-post/${post.id}`}><a className="text-sm mr-4 text-blue-500">Edit Post</a></Link>
            <Link href={`/posts/${post.id}`}><a className="text-sm mr-4 text-blue-500">View Post</a></Link>
            <button
              className="text-sm mr-4 text-red-500"
              onClick={() => deletePost(post.id)}
            >Delete Post</button>
          </div>
        ))
      }
    </div>
  )
}

在对 post 的查询中,我们使用用户 id,选择由登录用户创建的帖子。

接下来,在pages目录下创建一个名为edit-post的新文件夹。然后,在这个文件夹中创建一个名为 [id\].js 的文件。

在这个文件中,我们将从一个路由参数中获取访问帖子的`id'。当组件加载时,我们将使用来自路由的帖子ID来获取帖子数据,并使其可用于编辑。

在这个文件中,添加以下代码:

// pages/edit-post/[id].js
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import dynamic from 'next/dynamic'
import "easymde/dist/easymde.min.css"
import { supabase } from '../../api'

const SimpleMDE = dynamic(() => import('react-simplemde-editor'), { ssr: false })

function EditPost() {
  const [post, setPost] = useState(null)
  const router = useRouter()
  const { id } = router.query

  useEffect(() => {
    fetchPost()
    async function fetchPost() {
      if (!id) return
      const { data } = await supabase
        .from('posts')
        .select()
        .filter('id', 'eq', id)
        .single()
      setPost(data)
    }
  }, [id])
  if (!post) return null
  function onChange(e) {
    setPost(() => ({ ...post, [e.target.name]: e.target.value }))
  }
  const { title, content } = post
  async function updateCurrentPost() {
    if (!title || !content) return
    await supabase
      .from('posts')
      .update([
          { title, content }
      ])
      .match({ id })
    router.push('/my-posts')
  }
  return (
    <div>
      <h1 className="text-3xl font-semibold tracking-wide mt-6 mb-2">Edit post</h1>
      <input
        onChange={onChange}
        name="title"
        placeholder="Title"
        value={post.title}
        className="border-b pb-2 text-lg my-4 focus:outline-none w-full font-light text-gray-500 placeholder-gray-500 y-2"
      /> 
      <SimpleMDE value={post.content} onChange={value => setPost({ ...post, content: value })} />
      <button
        className="mb-4 bg-blue-600 text-white font-semibold px-8 py-2 rounded-lg"
        onClick={updateCurrentPost}>Update Post</button>
    </div>
  )
}

export default EditPost

现在,在位于pages/_app.js的导航中添加一个新链接:

// pages/_app.js
{
  user && (
    <Link href="/my-posts">
      <span className="mr-6 cursor-pointer">My Posts</span>
    </Link>
  )
}

当运行应用程序时,你应该能够查看你自己的帖子,编辑它们,并从更新用户界面删除它们。

如何启用实时更新

现在,我们已经运行了应用程序,添加实时更新是很容易的。

默认情况下,数据库中的Realtime是禁用的。让我们为posts表打开Realtime。

要做到这一点,打开应用程序仪表板并点击 Databases -> Replication -> 0 Tables (Source下面)。 打开 posts 表Realtime功能。这里 是一个关于如何做的视频演练,以使其清晰明了。

接下来,打开 src/index.js,用以下代码更新 useEffect hook:

  useEffect(() => {
    fetchPosts()
    const mySubscription = supabase
      .from('posts')
      .on('*', () => fetchPosts())
      .subscribe()
    return () => supabase.removeSubscription(mySubscription)
  }, [])

现在,我们将订阅posts表中的实时变化。

该应用程序的代码位于这里

下一步

现在你应该对如何用Supabase和Next.js构建全栈应用有了一定的了解。

如果你想了解更多关于用Supabase构建全栈应用的信息,我建议查看以下资源。