> For the complete documentation index, see [llms.txt](https://onenesss-organization.gitbook.io/jj-xiaoce/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://onenesss-organization.gitbook.io/jj-xiaoce/next.js-kai-fa-zhi-nan/68.-shi-zhan-pian-t3app-shi-zhan-shen-fen-ren-zheng-yu-zhu-ti-qie-huan.md).

# 68. 实战篇 ❘ t3-app 实战 ❘ 身份认证与主题切换

### 前言

我们使用 **Typescript + Tailwind + Prisma + MySQL + Zod + Shadcn UI + React Hook Form + Clerk + Server Actions** 开发一个清单项目。

项目效果如下：

![t32.gif](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/150472b462c74d3aaf8065a460cf4357~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1106\&h=1006\&s=1465739\&e=gif\&f=310\&b=fdfdfd)

### 1. 项目初始化

运行：

```bash
npm create t3-app@latest
```

> 1. Will you be using TypeScript or JavaScript?
>    * TypeScript
> 2. Will you be using Tailwind CSS for styling?
>    * Yes
> 3. Would you like to use tRPC?
>    * No
> 4. What authentication provider would you like to use?
>    * None（因为接入 Clerk）
> 5. What database ORM would you like to use?
>    * Prisma
> 6. Would you like to use Next.js App Router?
>    * Yes
> 7. What database provider would you like to use?
>    * MySQL（选择其他喜欢的数据库也行）
> 8. Should we initialize a Git repository and stage the changes?
>    * Yes
> 9. Should we run 'npm install' for you?
>    * Yes
> 10. What import alias would you like to use?
>     * @（按照个人习惯设置即可）

本地开启 MySQL 数据库，修改 `.env`中的数据库地址：

```bash
# 修改用户名、密码，用于连接本地数据库
# next-t3-todo 表示数据库名，数据库不需要先行创建
DATABASE_URL="mysql://username:password@localhost:3306/next-t3-todo"
```

运行：

```bash
# 进入项目目录
cd next-t3-todo
# 相当于 prisma db push
npm run db:push
```

效果如下：

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e222fd2999db460996641aa44164ee5f~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1666\&h=594\&s=118889\&e=png\&b=1f1f1f)

此时会创建数据库，并将数据库和 Prisma schema 同步。

运行：

```bash
# 开发模式
npm run dev
# 提交代码
git commit -m "initial commit"
```

我们的项目就正式开始了。

### 2. 功能：身份认证

我们先做身份认证，毕竟身份认证是基础，且创建清单的时候需要用户信息。

为了快速实现，最便捷的方式是接入 Clerk。我们将接入 Clerk 并实现界面的汉化。

#### 2.1. 接入 Clerk

安装依赖项：

```bash
# 安装依赖项，其中 lodash.merge 用于界面汉化
npm i --save @clerk/localizations @clerk/nextjs lodash.merge
# 安装开发依赖项
npm i --save-dev @types/lodash.merge
```

在 [Clerk](https://dashboard.clerk.com/) 创建一个应用，创建后查看密钥信息。

项目根目录新建 `.env.local`文件，代码如下：

```javascript
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
CLERK_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

新建 `src/middleware.ts`文件，代码如下：

```javascript
import { clerkMiddleware } from "@clerk/nextjs/server";

export default clerkMiddleware();

export const config = {
  matcher: ['/((?!.*\\..*|_next).*)', '/', '/(api|trpc)(.*)'],
};
```

> 注意： middleware.ts 并不一定放在项目根目录，放在与 app 同级目录位置

修改 `src/app/layout.tsx`，代码如下：

```jsx
import "@/styles/globals.css";

import { type Metadata } from "next";
import {
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from "@clerk/nextjs";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body>
          <header>
            <SignedOut>
              <SignInButton />
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header>
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}
```

重新运行 `npm run dev`，此时效果如下：

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/885cb5da153a450e9d37bb5ad99f7122~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=2000\&h=676\&s=128948\&e=png\&b=22135e)

#### 2.2. 自定义登录和注册地址

当我们点击 `Sign in` 的时候，跳转的其实是 Clerk 的地址，如果要修改为我们自己的路由地址该怎么做呢？

新建 `src/app/(auth)/sign-in/[[...sign-in]]/page.tsx`，代码为：

```jsx
import { SignIn } from "@clerk/nextjs";

export default function Page() {
  return <SignIn />;
}
```

新建 `src/app/(auth)/sign-up/[[...sign-up]]/page.tsx`，代码为：

```jsx
import { SignUp } from "@clerk/nextjs";

export default function Page() {
  return <SignUp />;
}
```

修改 `.env.local`文件，添加如下代码：

```bash
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
```

重新运行 `npm run dev`，浏览器效果如下：

![t33.gif](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/09a9b804f3fd44138635e701d5d11baf~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1106\&h=376\&s=235638\&e=gif\&f=61\&b=260e5f)

效果描述：当点击登录的时候，跳转的是 `http://localhost/sign-in`

#### 2.3. 界面汉化

修改 `src/app/layout.tsx`，完整代码如下：

```jsx
import "@/styles/globals.css";

import { type Metadata } from "next";
import {
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from "@clerk/nextjs";
// Step1: 引入汉化文件
import { zhCN } from "@clerk/localizations";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  // Step2: 设置 ClerkProvider 的 localization
  return (
    <ClerkProvider localization={zhCN}>
      {/** Step3: 别忘了 html lang */}
      <html lang="zh-CN">
        <body>
          <header>
            <SignedOut>
              {/** Step4: 如果按钮文案要改为中文 */}
              <SignInButton>登录</SignInButton>
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header>
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}
```

此时界面基本完成汉化，浏览器效果如下：

![t34.gif](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/308f631abcba4e0ba01a90ae0c5bb7d8~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1108\&h=548\&s=388125\&e=gif\&f=69\&b=260e5f)

但查看用户界面，你就会发现，这个汉化并不全面！就比如删除按钮这里的文案依然是英文……

新建 `src/locales/zh.json`，代码如下：

```javascript
{
  "userProfile": {
    "start": {
      "dangerSection": {
        "deleteAccountButton": "删除账户",
        "title": "账户终止"
      }
    }
  }
}
```

修改 `src/app/layout.tsx`，代码如下：

```jsx
import "@/styles/globals.css";

import { type Metadata } from "next";
import {
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
// Step1: 引入翻译文件
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  // Step2: 合并翻译文件
  const localization = merge(zhCN, zhCNlocales);
  // Step3: 设置 ClerkProvider 的 localization
  return (
    <ClerkProvider localization={localization}>
      <html lang="zh-CN">
        <body>
          <header>
            <SignedOut>
              <SignInButton>登录</SignInButton>
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header>
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}

```

此时删除账户界面就改为了中文：

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ab6276bd354840658a37720c8cc57afa~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=2210\&h=370\&s=31090\&e=png\&b=ffffff)

那么问题来了：如果还有其他需要汉化的位置，怎么知道具体的字段位置呢？就比如为什么“删除账户”，它对应的字段位置是 userProfile.start.dangerSection.deleteAccountButton 呢？

其实这是个体力活：打开原本的[英文](https://github.com/clerk/javascript/blob/main/packages/localizations/src/en-US.ts)，搜索对应文案，就可以找到具体的字段位置了。

> 注意：目前中文翻译并不全面，这是一个给 Clerk 提 PR 的好机会！

#### 2.4. 设置路由保护

毕竟我们开发的是一个清单项目，当用户打开首页的时候，应该是登录后才能查看到自己创建的清单。所以我们实现一个路由保护，当用户访问 `/`的时候，会跳转到 `/sign-in`，引导用户登录。

修改 `src/middleware.ts`，完整代码如下：

```javascript
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher(["/sign-in(.*)", "/sign-up(.*)"]);

export default clerkMiddleware((auth, request) => {
  if (!isPublicRoute(request)) {
    auth().protect();
  }
});

export const config = {
  matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"],
};
```

在这段代码中，我们设置了 `/sign-in` 和 `/sign-up` 为公共路由，访问其他路由，都会触发路由保护，跳转到登录页面。

> 注意：这里中间件的 matcher 逻辑是，除了内部路由 `_next` 和静态文件之外，其他都会受到保护

修改 `src/app/layout.tsx`，代码如下：

```jsx
import "@/styles/globals.css";

import { type Metadata } from "next";
import {
  ClerkProvider,
  SignInButton,
  SignedIn,
  SignedOut,
  UserButton,
} from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  const localization = merge(zhCN, zhCNlocales);
  return (
    <ClerkProvider localization={localization}>
      <html lang="zh-CN">
        <body>
          {/* 注释或删除下面这段代码，在使用路由保护的时候会导致错误 */}
          {/* <header>
            <SignedOut>
              <SignInButton>登录</SignInButton>
            </SignedOut>
            <SignedIn>
              <UserButton />
            </SignedIn>
          </header> */}
          <main>{children}</main>
        </body>
      </html>
    </ClerkProvider>
  );
}
```

这是因为使用 `<SignedIn>` 和 `<SignedOut>` 组件会导致报错：

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c0dc8110f5cc4d9ca4204dd87b1bd99d~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1696\&h=390\&s=104499\&e=png\&b=1e1e1e)

现在我们就完成了 Clerk 的基础设置。浏览器效果如下：

![t35.gif](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4f259171100b41d380999d811ade0e1e~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=965\&h=554\&s=89133\&e=gif\&f=46\&b=fefefe)

效果描述：访问首页，会跳转到登录页面，登录完成后，跳转会首页。

### 3. 功能：支持深色模式

接下来我们支持深色模式，我们借助 Shadcn UI 实现。

#### 3.1. 接入 Shadcn UI

初始化 Shadcn UI：

```bash
npx shadcn-ui@latest init
```

命令行效果如下：

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e702766386c046afbc830f3f79a00eb0~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=1554\&h=438\&s=109447\&e=png\&b=1e1e1e)

添加组件：

```bash
npx shadcn-ui@latest add
```

因为用到的组件很多，干脆全装了。敲击键盘的`a`，作用是全选组件（再敲击一次就是取消全选），然后进行安装。

#### 3.2. 实现主题切换器

修改 `src/app/page.tsx`，代码如下：

```jsx
export default function HomePage() {
  return (
    <main className="flex w-full flex-col items-center">
      <div>Hello World!</div>
    </main>
  );
}
```

修改 `src/app/layout.tsx`，完整代码如下：

```jsx
import "@/styles/globals.css";

import { type Metadata } from "next";
import { ClerkProvider } from "@clerk/nextjs";
import { zhCN } from "@clerk/localizations";
import zhCNlocales from "@/locales/zh.json";
import merge from "lodash.merge";
// Step1: 添加组件
import ThemeProvider from "@/components/ThemeProvider";
import Header from "@/components/Header";

export const metadata: Metadata = {
  title: "Create T3 App",
  description: "Generated by create-t3-app",
  icons: [{ rel: "icon", url: "/favicon.ico" }],
};

export default function RootLayout({
  children,
}: Readonly<{ children: React.ReactNode }>) {
  const localization = merge(zhCN, zhCNlocales);
  return (
    <ClerkProvider localization={localization}>
      {/* Step2: 设置 suppressHydrationWarning */}
      <html lang="zh-CN" suppressHydrationWarning>
        <body>
          {/* Step3: 设置 ThemeProvider */}
          <ThemeProvider
            attribute="class"
            defaultTheme="system"
            enableSystem
            disableTransitionOnChange
          >
            <Header />
            <div className="flex w-full flex-col items-center">{children}</div>
          </ThemeProvider>
        </body>
      </html>
    </ClerkProvider>
  );
}
```

新建 `src/components/ThemeProvider.tsx`，代码如下：

```jsx
"use client";

import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";

export default function ThemeProvider({
  children,
  ...props
}: ThemeProviderProps) {
  return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}
```

新建 `src/components/Header.tsx`，代码如下：

```jsx
import { UserButton } from "@clerk/nextjs";
import ThemeToggle from "./ThemeToggle";

export default function Header() {
  return (
    <nav className="flex h-[60px] w-full items-center justify-between p-4">
      <h1>嗒嗒清单</h1>
      <div className="flex items-center gap-2">
        <UserButton />
        <ThemeToggle />
      </div>
    </nav>
  );
}
```

新建 `src/components/ThemeToggle.tsx`，代码如下：

```jsx
"use client";

import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";

import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export default function ModeToggle() {
  const { setTheme } = useTheme();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="icon">
          <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
          <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
          <span className="sr-only">Toggle theme</span>
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onClick={() => setTheme("light")}>
          Light
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("dark")}>
          Dark
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => setTheme("system")}>
          System
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}
```

此时浏览器效果如下：

![t3-6.gif](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4efb6a86a30740f8909ef3545000c670~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=959\&h=408\&s=67082\&e=gif\&f=33\&b=fefefe)

> 注：更复杂的效果，比如修改主题色、增加主题请查看[《实战篇 | Shadcn UI 与组件库》](https://juejin.cn/book/7307859898316881957/section/7385045738771709979)

### 4. 功能：欢迎信息

修改 `src/app/page.tsx`，完整代码如下：

```jsx
import { Suspense } from "react";
import {
  Card,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { currentUser } from "@clerk/nextjs/server";
import CreateListModal from "@/components/createListModal";

async function Welcome() {
  const user = await currentUser();

  if (!user) return null;

  return (
    <Card className="w-full sm:col-span-2" x-chunk="dashboard-05-chunk-0">
      <CardHeader className="pb-3">
        <CardTitle className="text-lg">
          欢迎 {user.firstName} {user.lastName}!
        </CardTitle>
        <CardDescription className="max-w-lg text-balance leading-relaxed">
          道虽迩，不行不至；事虽小，不为不成
        </CardDescription>
      </CardHeader>
      <CardFooter>
        <CreateListModal />
      </CardFooter>
    </Card>
  );
}

function WelcomeFallback() {
  return <Skeleton className="h-[180px] w-full" />;
}

export default function HomePage() {
  return (
    <main className="flex w-full flex-col items-center px-4">
      <Suspense fallback={<WelcomeFallback />}>
        <Welcome />
      </Suspense>
    </main>
  );
}
```

在这段代码中，我们创建了一个 `<Welcome>`组件，当涉及到服务端请求时，应该尽可能将请求放到 `<Suspense>` 中，这样就不会阻塞页面的请求和渲染。

新建 `src/components/CreateListModal.tsx`，代码如下：

```jsx
"use client";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";

export default function Sidebar() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button>添加清单</Button>
      </SheetTrigger>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>添加清单</SheetTitle>
          <SheetDescription>
            清单是任务的集合，比如“工作”、“生活”、“副业”
          </SheetDescription>
        </SheetHeader>
        <div className="grid gap-4 py-4">
          <div className="grid grid-cols-4 items-center gap-4">
            <Label htmlFor="name" className="text-right">
              清单名称：
            </Label>
            <Input
              id="name"
              value="工作"
              onChange={() => {
                console.log(1);
              }}
              className="col-span-3"
              />
          </div>
        </div>
        <SheetFooter>
          <SheetClose asChild>
            <Button type="submit">创建</Button>
          </SheetClose>
        </SheetFooter>
      </SheetContent>
    </Sheet>
  );
}
```

此时浏览器效果如下：

![t3-7.gif](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/62bd6473108b4ceea002a9d68eb3fc48~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=959\&h=408\&s=108861\&e=gif\&f=24\&b=fefefe)

### 下一篇

> 1. 功能实现：t3-app 身份认证和深色模式
> 2. 源码地址：<https://github.com/mqyqingfeng/next-app-demo/tree/next-t3-todo>
> 3. 下载代码：`git clone -b next-t3-todo git@github.com:mqyqingfeng/next-app-demo.git`

目前我们已经用 Clerk 实现了身份认证，使用 Shadcn UI + next-themes 实现了深色模式切换。当点击“添加清单”按钮的时候，右侧会弹出创建清单的表单，现在我们只是简单模拟了下大致效果。下一篇我们会用 Shadcn UI + React Hook Form + Zod + Server Actions 实现清单的创建和查询功能。
