使

使用 create-t3-app 构建全栈应用程序

2025-06-07

使用 create-t3-app 构建全栈应用程序

这篇博文已经过时了。我没时间继续更新它了。

大家好!今天我们将使用T3 技术栈构建一个应用程序。我们将受Lee Robinson 的留言簿启发,构建一个留言簿。让我们开始吧!

入门

让我们建立一个启动项目create-t3-app



npm create t3-app@latest


Enter fullscreen mode Exit fullscreen mode

我们将利用堆栈的所有部分。

创建-t3-app

我们还可以在Railway上设置一个 Postgres 数据库。Railway 让快速设置数据库变得非常简单。

前往Railway并使用 GitHub 登录(如果尚未登录)。现在点击New Project

新项目

现在提供 Postgres。

提供 Postgres

就这么简单。从Connect选项卡复制连接字符串。

复制连接字符串

开始编码吧!用你最喜欢的代码编辑器打开项目。

文件夹很多,但不要感到不知所措。以下是基本概览。

  • prisma/*-prisma模式。
  • public/*- 静态资产包括字体和图像。
  • src/env/*- 环境变量的验证。
  • src/pages/*- 网站的所有页面。
  • src/server/*- 后端,包括 tRPC 服务器、Prisma 客户端和身份验证实用程序。
  • src/styles/*- 全局 CSS 文件,但我们将对大多数样式使用 Tailwind CSS。
  • src/types/*- 下一个 Auth 类型声明。
  • src/utils/*- 实用功能。

复制.env.example文件并将新副本重命名为.env。打开.env文件并将连接字符串粘贴到中DATABASE_URL

您会注意到,我们已经使用 设置了Discordnext-auth OAuth ,因此我们还需要DISCORD_CLIENT_IDDISCORD_CLIENT_SECRET。让我们来设置一下。

设置身份验证

转到Discord 开发者门户并创建一个新的应用程序。

Discord 上的新应用

前往OAuth2/General并将所有回调 URL 添加到Redirects。对于 localhost,回调 URL 为http://localhost:3000/api/auth/callback/discord。我还提前添加了生产 URL。

添加重定向

复制客户端 ID 和密钥并将它们粘贴到 中.env

复制客户端 ID 和密钥

取消注释NEXTAUTH_SECRET并将其设置为随机字符串。现在我们已经配置好了所有环境变量。

我们还将数据库更改为postgresql,并取消注释中模型@db.Text中的注释。您在架构中看到的所有模型都是 Next Auth 工作所必需的。Accountprisma/schema.prisma

prisma/schema.prisma 中的变化

让我们将此模式推送到 Railway Postgres 数据库。此命令会将我们的模式推送到 Railway,并为 Prisma 客户端生成类型定义。



npx prisma db push


Enter fullscreen mode Exit fullscreen mode

现在运行开发服务器。



npm run dev


Enter fullscreen mode Exit fullscreen mode

转到src/pages/index.tsx文件并删除所有代码,我们只需渲染一个标题。



// src/pages/index.tsx

const Home = () => {
  return (
    <main>
      <h1>Guestbook</h1>
    </main>
  );
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

呈现标题

我无法查看浅色主题,因此让我们应用一些全局样式来src/styles/globals.css使该应用程序成为深色主题。



/* src/styles/globals.css */

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

body {
  @apply bg-neutral-900 text-neutral-100;
}


Enter fullscreen mode Exit fullscreen mode

深色主题

好多了。

如果您查看src/pages/api/auth/[...nextauth].ts,您会发现我们已经使用 Next Auth 设置了 Discord OAuth。在这里您可以添加更多 OAuth 提供商,例如 Google、Twitter 等。

现在让我们创建一个按钮,让用户使用 Discord 登录。我们可以使用Next Auth 中的signIn()功能。



// src/pages/index.tsx

import { signIn } from "next-auth/react";

const Home = () => {
  return (
    <main>
      <h1>Guestbook</h1>
      <button
        onClick={() => {
          signIn("discord").catch(console.log);
        }}
      >
        Login with Discord
       </button>
    </main>
  );
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

我们可以使用useSession()钩子来获取用户的会话。同时,我们还可以使用该signOut()函数来实现注销功能。



// src/pages/index.tsx

import { signIn, signOut, useSession } from "next-auth/react";

const Home = () => {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <main>Loading...</main>;
  }

  return (
    <main>
      <h1>Guestbook</h1>
      <div>
        {session ? (
          <>
            <p>hi {session.user?.name}</p>
            <button
              onClick={() => {
                signOut().catch(console.log);
              }}
            >
              Logout
            </button>
          </>
        ) : (
          <button
            onClick={() => {
              signIn("discord").catch(console.log);
            }}
          >
            Login with Discord
           </button>
        )}
      </div>
    </main>
  );
};

export default Home;


Enter fullscreen mode Exit fullscreen mode

授权作品

太棒了!现在我们的授权已经成功了。Next Auth 真的让一切变得超级简单。

后端

现在我们来处理后端。我们将使用tRPC作为 API 层,并使用Prisma连接和查询数据库。

我们需要修改 Prisma 架构并添加一个Guestbook模型。留言簿中的每条消息都将包含一个名称和一条消息。模型如下所示。



model Guestbook {
    id        String   @id @default(cuid())
    createdAt DateTime @default(now())
    name      String
    message   String   @db.VarChar(100)
}


Enter fullscreen mode Exit fullscreen mode

让我们将这个修改后的模式推送到我们的 Railway Postgres 数据库。



npx prisma db push


Enter fullscreen mode Exit fullscreen mode

现在让我们进入有趣的部分——tRPC 时间。继续,删除文件夹example.ts中的文件src/server/api/routers。然后在同一个文件夹中,创建一个名为 的新文件guestbook.ts

首先,我们将定义一个突变来将消息发布到我们的数据库。



// src/server/api/routers/guestbook.ts

import { z } from "zod";
import { createTRPCRouter, publicProcedure } from "../trpc";

export const guestbookRouter = createTRPCRouter({
  postMessage: publicProcedure
    .input(
      z.object({
        name: z.string(),
        message: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      try {
        await ctx.prisma.guestbook.create({
          data: {
            name: input.name,
            message: input.message,
          },
        });
      } catch (error) {
        console.log(error);
      }
    }),
});


Enter fullscreen mode Exit fullscreen mode

这里我们有一个 tRPC 突变,它使用zod来验证输入,并有一个异步函数,运行单个 prisma 查询以在Guestbook表中创建新行。

Prisma 的使用体验非常棒。它的自动补全和类型安全功能非常棒。

我们还希望这个变更能够得到保护。这里我们可以使用tRPC 中间件

如果您查看该src/server/auth.ts文件,我们会发现我们正在使用unstable_getServerSessionNext Auth,它使我们能够访问服务器上的会话。



// src/server/auth.ts

import { type GetServerSidePropsContext } from "next";
import { unstable_getServerSession } from "next-auth";
import { authOptions } from "../pages/api/auth/[...nextauth]";

export const getServerAuthSession = async (ctx: {
  req: GetServerSidePropsContext["req"];
  res: GetServerSidePropsContext["res"];
}) => {
  return await unstable_getServerSession(ctx.req, ctx.res, authOptions);
};


Enter fullscreen mode Exit fullscreen mode

我们将该会话传递到我们的 tRPC 上下文中。



// src/server/api/trpc.ts

export const createTRPCContext = async (opts: CreateNextContextOptions) => {
  const { req, res } = opts;
  const session = await getServerAuthSession({ req, res });
  return createInnerTRPCContext({
    session,
  });
};


Enter fullscreen mode Exit fullscreen mode

然后,我们可以使用此会话来保护我们的变异protectedProcedure



// src/server/api/trpc.ts

const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session || !ctx.session.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({
    ctx: {
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);


Enter fullscreen mode Exit fullscreen mode

现在,替换publicProcedureprotectedProcedure,以使我们的变异免受未经身份验证的用户的攻击。



// src/server/api/routers/guestbook.ts

import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "../trpc";

export const guestbookRouter = createTRPCRouter({
  postMessage: protectedProcedure
    .input(
      z.object({
        name: z.string(),
        message: z.string(),
      })
    )
    .mutation(async ({ ctx, input }) => {
      try {
        await ctx.prisma.guestbook.create({
          data: {
            name: input.name,
            message: input.message,
          },
        });
      } catch (error) {
        console.log(error);
      }
    }),
});


Enter fullscreen mode Exit fullscreen mode

接下来,我们编写一个查询来获取留言簿中的所有消息。我们希望所有访客都能看到这些消息,因此我们将使用publicProcedure



// src/server/api/routers/guestbook.ts

import { z } from "zod";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";

export const guestbookRouter = createTRPCRouter({
  getAll: publicProcedure.query(async ({ ctx }) => {
    try {
      return await ctx.prisma.guestbook.findMany({
        select: {
          name: true,
          message: true,
        },
        orderBy: {
          createdAt: "desc",
        },
      });
    } catch (error) {
      console.log("error", error);
    }
  }),
  //...


Enter fullscreen mode Exit fullscreen mode

这里我们只获取模型中所有行的名称和消息Guestbook。行按字段降序排列createdAt



现在将此路由器合并到主路由器中appRouter



// src/server/api/root.ts

import { createTRPCRouter } from "./trpc";
import { guestbookRouter } from "./routers/guestbook";

export const appRouter = createTRPCRouter({
  guestbook: guestbookRouter,
});

// export type definition of API
export type AppRouter = typeof appRouter;


Enter fullscreen mode Exit fullscreen mode

后端部分基本已经完成了。接下来我们来处理 UI。

前端

让我们首先将一切置于中心。



// src/pages/index.tsx

import { signIn, signOut, useSession } from "next-auth/react";

const Home = () => {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <main className="flex flex-col items-center pt-4">Loading...</main>;
  }

  return (
    <main className="flex flex-col items-center">
      <h1 className="text-3xl pt-4">Guestbook</h1>
      <p>
        Tutorial for <code>create-t3-app</code>
      </p>
      <div className="pt-10">
        <div>
          {session ? (
            <>
              <p className="mb-4 text-center">hi {session.user?.name}</p>
              <button
                type="button"
                className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
                onClick={() => {
                  signOut().catch(console.log);
                }}
              >
                Logout
              </button>
            </>
          ) : (
            <button
              type="button"
              className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
              onClick={() => {
                signIn("discord").catch(console.log);
              }}
            >
              Login with Discord
            </button>
          )}
        </div>
      </div>
    </main>
  );
};
export default Home;


Enter fullscreen mode Exit fullscreen mode

我还把标题做得更大,并在元素之间添加了一些填充。

让我们使用 tRPC 查询来获取数据库中留言簿的所有消息。但是我们目前没有任何数据。我们可以使用Prisma Studio手动添加一些数据。



npx prisma studio


Enter fullscreen mode Exit fullscreen mode

它会自动打开http://localhost:5555。转到Guestbook表并像这样添加一堆记录。

使用 Prisma Studio

现在我们有了数据,就可以使用查询并显示数据了。为此,我们可以使用 tRPCreact-query包装器。让我们在 中为此创建一个组件src/pages/index.tsx



// src/pages/index.tsx

import { api } from "../utils/api";

const GuestbookEntries = () => {
  const { data: guestbookEntries, isLoading } = api.guestbook.getAll.useQuery();

  if (isLoading) return <div>Fetching messages...</div>;

  return (
    <div className="flex flex-col gap-4">
      {guestbookEntries?.map((entry, index) => {
        return (
          <div key={index}>
            <p>{entry.message}</p>
            <span>- {entry.name}</span>
          </div>
        );
      })}
    </div>
  );
};


Enter fullscreen mode Exit fullscreen mode

这里我们使用useQuery()并映射它返回的数组。

当然,这里我们也有出色的类型安全和自动完成功能。

现在在组件中渲染这个组件Home



// src/pages/index.tsx

<main className="flex flex-col items-center">
  <h1 className="text-3xl pt-4">Guestbook</h1>
  <p>
    Tutorial for <code>create-t3-app</code>
  </p>
  <div className="pt-10">
    <div>
      {session ? (
        <>
          <p className="mb-4 text-center">hi {session.user?.name}</p>
          <button
            type="button"
            className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
            onClick={() => {
              signOut().catch(console.log);
            }}
          >
            Logout
          </button>
        </>
      ) : (
        <button
          type="button"
          className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
          onClick={() => {
            signIn("discord").catch(console.log);
          }}
        >
          Login with Discord
        </button>
      )}
      <div className="pt-10">
        <GuestbookEntries />
      </div>
    </div>
  </div>
</main>


Enter fullscreen mode Exit fullscreen mode

现在让我们创建一个表单组件src/pages/index.tsx并在其中使用我们的 tRPC 变异。



// src/pages/index.tsx

const Form = () => {
  const [message, setMessage] = useState("");
  const { data: session, status } = useSession();

  const postMessage = api.guestbook.postMessage.useMutation();

  if (status !== "authenticated") return null;

  return (
    <form
      className="flex gap-2"
      onSubmit={(event) => {
        event.preventDefault();
        postMessage.mutate({
          name: session.user?.name as string,
          message,
        });
        setMessage("");
      }}
    >
      <input
        type="text"
        className="rounded-md border-2 border-zinc-800 bg-neutral-900 px-4 py-2 focus:outline-none"
        placeholder="Your message..."
        minLength={2}
        maxLength={100}
        value={message}
        onChange={(event) => setMessage(event.target.value)}
      />
      <button
        type="submit"
        className="rounded-md border-2 border-zinc-800 p-2 focus:outline-none"
      >
        Submit
      </button>
    </form>
  );
};


Enter fullscreen mode Exit fullscreen mode

Form我们现在可以在组件中渲染Home并添加一些填充。



// src/pages/index.tsx

<div>
  {session ? (
    <>
      <p className="mb-4 text-center">hi {session.user?.name}</p>
      <button
        type="button"
        className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
        onClick={() => {
          signOut().catch(console.log);
        }}
      >
        Logout
      </button>
      <div className="pt-6">
        <Form />
      </div>
    </>
  ) : (
    <button
      type="button"
      className="mx-auto block rounded-md bg-neutral-800 py-3 px-6 text-center hover:bg-neutral-700"
      onClick={() => {
        signIn("discord").catch(console.log);
      }}
    >
      Login with Discord
    </button>
  )}
  <div className="pt-10">
    <GuestbookEntries />
  </div>
</div>


Enter fullscreen mode Exit fullscreen mode

这里我们有一个表单,用于useMutation()将数据提交到数据库。但你会注意到一个问题。当我们点击提交按钮时,它确实将消息提交到数据库,但用户并没有立即收到任何反馈。只有刷新页面,用户才能看到新消息。

为此,我们可以使用乐观 UI 更新!react-query让这一切变得轻而易举。我们只需要在组件useMutation()内部的钩子中添加一些内容即可Form



// src/pages/index.tsx

const utils = api.useContext();
const postMessage = api.guestbook.postMessage.useMutation({
  onMutate: async (newEntry) => {
    await utils.guestbook.getAll.cancel();
    utils.guestbook.getAll.setData(undefined, (prevEntries) => {
      if (prevEntries) {
        return [newEntry, ...prevEntries];
      } else {
        return [newEntry];
      }
    });
  },
  onSettled: async () => {
    await utils.guestbook.getAll.invalidate();
  },
});


Enter fullscreen mode Exit fullscreen mode

如果您想了解有关此代码示例的更多信息,可以在@tanstack/react-query 此处阅读有关乐观更新的内容。

编码部分基本完成了!是不是很简单?T3 堆栈让构建全栈 Web 应用变得超级简单快捷。现在,我们来部署留言簿吧。

部署

我们将使用Vercel进行部署。Vercel 让 NextJS 应用的部署变得非常简单,他们就是 NextJS 的开发者。

首先,将您的代码推送到GitHub存储库。现在,如果您还没有注册 GitHub,请前往Vercel并注册。

韦尔塞尔

然后单击New Project并导入您新创建的存储库。

导入存储库

现在我们需要添加环境变量,因此将所有环境变量复制粘贴到 Vercel。完成后,点击Deploy

添加环境变量,然后点击部署

如果有自定义域名,请添加,大功告成!恭喜!

所有代码都可以在这里找到。您可以访问网站:guestbook.nxl.sh

致谢

文章来源:https://dev.to/nexxeln/build-a-full-stack-app-with-create-t3-app-5e1e
PREV
计算机科学中的难题
NEXT
深度学习基础:速成课程