使用 create-t3-app 构建全栈应用程序
这篇博文已经过时了。我没时间继续更新它了。
大家好!今天我们将使用T3 技术栈构建一个应用程序。我们将受Lee Robinson 的留言簿启发,构建一个留言簿。让我们开始吧!
入门
让我们建立一个启动项目create-t3-app
!
npm create t3-app@latest
我们将利用堆栈的所有部分。
我们还可以在Railway上设置一个 Postgres 数据库。Railway 让快速设置数据库变得非常简单。
前往Railway并使用 GitHub 登录(如果尚未登录)。现在点击New Project
。
现在提供 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_ID
和DISCORD_CLIENT_SECRET
。让我们来设置一下。
设置身份验证
转到Discord 开发者门户并创建一个新的应用程序。
前往OAuth2/General
并将所有回调 URL 添加到Redirects
。对于 localhost,回调 URL 为http://localhost:3000/api/auth/callback/discord
。我还提前添加了生产 URL。
复制客户端 ID 和密钥并将它们粘贴到 中.env
。
取消注释NEXTAUTH_SECRET
并将其设置为随机字符串。现在我们已经配置好了所有环境变量。
我们还将数据库更改为postgresql
,并取消注释中模型@db.Text
中的注释。您在架构中看到的所有模型都是 Next Auth 工作所必需的。Account
prisma/schema.prisma
让我们将此模式推送到 Railway Postgres 数据库。此命令会将我们的模式推送到 Railway,并为 Prisma 客户端生成类型定义。
npx prisma db push
现在运行开发服务器。
npm run dev
转到src/pages/index.tsx
文件并删除所有代码,我们只需渲染一个标题。
// src/pages/index.tsx
const Home = () => {
return (
<main>
<h1>Guestbook</h1>
</main>
);
};
export default Home;
我无法查看浅色主题,因此让我们应用一些全局样式来src/styles/globals.css
使该应用程序成为深色主题。
/* src/styles/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-neutral-900 text-neutral-100;
}
好多了。
如果您查看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;
我们可以使用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;
太棒了!现在我们的授权已经成功了。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)
}
让我们将这个修改后的模式推送到我们的 Railway Postgres 数据库。
npx prisma db push
现在让我们进入有趣的部分——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);
}
}),
});
这里我们有一个 tRPC 突变,它使用zod来验证输入,并有一个异步函数,运行单个 prisma 查询以在Guestbook
表中创建新行。
Prisma 的使用体验非常棒。它的自动补全和类型安全功能非常棒。
我们还希望这个变更能够得到保护。这里我们可以使用tRPC 中间件。
如果您查看该src/server/auth.ts
文件,我们会发现我们正在使用unstable_getServerSession
Next 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);
};
我们将该会话传递到我们的 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,
});
};
然后,我们可以使用此会话来保护我们的变异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);
现在,替换publicProcedure
为protectedProcedure
,以使我们的变异免受未经身份验证的用户的攻击。
// 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);
}
}),
});
接下来,我们编写一个查询来获取留言簿中的所有消息。我们希望所有访客都能看到这些消息,因此我们将使用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);
}
}),
//...
这里我们只获取模型中所有行的名称和消息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;
后端部分基本已经完成了。接下来我们来处理 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;
我还把标题做得更大,并在元素之间添加了一些填充。
让我们使用 tRPC 查询来获取数据库中留言簿的所有消息。但是我们目前没有任何数据。我们可以使用Prisma Studio手动添加一些数据。
npx prisma studio
它会自动打开http://localhost:5555
。转到Guestbook
表并像这样添加一堆记录。
现在我们有了数据,就可以使用查询并显示数据了。为此,我们可以使用 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>
);
};
这里我们使用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>
现在让我们创建一个表单组件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>
);
};
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>
这里我们有一个表单,用于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();
},
});
如果您想了解有关此代码示例的更多信息,可以在@tanstack/react-query
此处阅读有关乐观更新的内容。
编码部分基本完成了!是不是很简单?T3 堆栈让构建全栈 Web 应用变得超级简单快捷。现在,我们来部署留言簿吧。
部署
我们将使用Vercel进行部署。Vercel 让 NextJS 应用的部署变得非常简单,他们就是 NextJS 的开发者。
首先,将您的代码推送到GitHub存储库。现在,如果您还没有注册 GitHub,请前往Vercel并注册。
然后单击New Project
并导入您新创建的存储库。
现在我们需要添加环境变量,因此将所有环境变量复制粘贴到 Vercel。完成后,点击Deploy
。
如果有自定义域名,请添加,大功告成!恭喜!
所有代码都可以在这里找到。您可以访问网站:guestbook.nxl.sh。
致谢
- Ayanava Karmakar使用 tRPC v10 更新博客。
- Julius Marminge和Michael Lee审阅了更新后的博客。
- Lee Robinson提出了留言簿的想法。
- 安东尼提出了建设性的批评。
- JAR和Krish负责校对。
- Hakan Güçlü根据最新
create-t3-app
模板 (7.3.0) 更新项目文件。