前言
最近在使用 next.js
开发项目,发现开发起来还不错,所以在这里总结出来一个项目启动模版给大家分享一下,所使用到的技术栈有 next.js
、trpc
、auth.js
、tailwind
、drizzle ORM
等,覆盖了目前前端的主流技术。
在使用过程当中,我感觉使用这一套技术栈进行 web 开发,真的能够大大的提高我们整体的开发效率,像登录、注册、包括第三方的登录注册的这种,只需要几行代码就能成功实现。
并且在我的开发过程中发现,使用这一套技术栈完全能够胜任小型应用的后端开发。
接下来,我将通过整篇文章带大家上手使用这一套技术栈进行开发。
介绍
在实际动手开发之前,我先对整个技术栈做一个简单的介绍。
Next.js
官网地址:https://nextjs.org/
Next.js 是基于 React 的全栈框架,支持多种渲染策略(SSR/SSG/ISR)和现代 Web 开发需求,简单来说就是有部分代码在 node.js
的服务器里运行,有部分代码是在浏览器里运行的。
tRPC
官网地址:https://trpc.io/
tRPC 是一个基于 TypeScript 的远程过程调用框架,能够简化客户端和服务端之间的通信,在实际开发过程当中的体验就是不需要再去定义各种 HTTP 接口的路由地址,而是直接采用函数调用的形式,并且因为它是基于 TypeScript 的原因,有非常强大的类型提示,开发起来非常方便。
TanStack Query
官网地址:https://tanstack.com/query
就是之前的 React Query,使用这个能够帮助开发者优雅的管理数据,开发者不用再重复的去写一堆处理前端请求过程当中的状态、报错等等问题,并且能够缓存数据,减少不必要的接口调用。
Auth.js
官网地址:https://authjs.dev/
auth.js 是 next-auth 的 v5 版本,使用它能够非常方便的在我们的 next.js 项目当中集成用户认证、授权等等功能。
drizzle ORM
官网地址:https://orm.drizzle.team/
一个 node.js 的 ORM 框架,能够让开发者使用面向对象的方式来操作数据库当中的数据,并且它使用起来非常直观方便,如果你会写 SQL 的话,使用它会非常得心应手。
实战
下面的步骤展示的是具体的文件创建步骤,如果你直接需要源码的话,可以直接使用访问这个 GitHub 仓库来获取源码。
https://github.com/DimplesY/next-full-stack-demo
创建项目
使用下面的命令创建项目,设置项按图中所示即可。
pnpx create-next-app@latest next-full-stack
集成 tRPC
然后我再安装一下 tRPC
+ TanStack Query
整合的相关依赖,直接复制下面的命令到项目的目录下安装即可。
pnpm add @trpc/server@next @trpc/client@next @trpc/react-query@next @trpc/next@next @tanstack/react-query@latest server-only
为了能够更好地处理 tRPC 的类型校验和数据转换,我们还需要使用到 superjson
和 zod
这两个依赖,使用下面的命令安装即可。
pnpm add zod superjson
使用你喜欢的开发工具打开项目,按照下面的三个步骤创建文件。
- 然后在
app
目录里创建api/trpc/[trpc]
目录,然后在[trpc]
当中创建route.ts
文件。 - 在项目的根目录下创建
server
目录,这部分作为我们服务器端的代码,然后在其中的api
目录下创建root.ts
和trpc.ts
文件。 - 在项目的根目录下创建
trpc
目录,并且在其中创建query-client.ts
、react.tsx
、server.ts
三个文件。
整个创建之后的文件目录结构如下图所示:
集成 drizzle ORM
文件创建完毕之后,我们再来集成数据库 ORM 框架 -> drizzle ORM
。
我们先安装一下 drizzle ORM
需要的依赖, 这里我使用 PostgreSQL
作为数据库来存储数据 ,直接执行下面的命令进行安装即可。
pnpm add postgres drizzle-orm
为了方便进行数据库迁移操作,还需要使用到 drizzle ORM
提供的 drizzle-kit
包。
pnpm add drizzle-kit -D
在项目根目录下的 server 文件夹当中创建如图所示的 db/index.ts
文件和 db/schema.ts
文件。
为了能够让 drizzle ORM
能够链接到我们的数据库,我们还需要创建一个环境变量以及 drizzle.config.ts
配置文件。
# .env
DATABASE_URL="postgresql://postgres:123456@localhost:5432/fullstack"
import { type Config } from "drizzle-kit";
export default {
schema: "./server/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL,
},
tablesFilter: ["fullstack_*"],
} satisfies Config;
为了更好的 typescript
体验,我们创建一个全局的 process.env.DATABASE_URL
类型。
在项目根目录下创建 types/global.d.ts
文件。
declare namespace NodeJS {
interface ProcessEnv {
readonly DATABASE_URL: string;
}
}
集成 Auth.js
上面的数据库 ORM 我们已经成功的集成进来了,接下来我们来安装授权相关的东西,直接使用 auth.js
(next-auth
的下一个版本) , 使用下面的命令进行安装。
pnpm add next-auth@beta @auth/drizzle-adapter
再在 .env 文件当中添加一个 next-auth
需要的密钥
AUTH_SECRET="xxx"
然后创建如下图所示的文件:
到这里我们已经将项目所需的文件都创建完毕了,接下来我把每个文件所需要的代码都贴到下面,并且将一些比较难以理解的地方做一些简单的介绍。
源码部分
定义 trpc
的服务器接口地址,并且将所有请求转换成 trpc 的函数调用。
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { type NextRequest } from "next/server";
import { appRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
const createContext = async (req: NextRequest) => {
return createTRPCContext({
headers: req.headers,
});
};
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: () => createContext(req),
onError:
process.env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`
);
}
: undefined,
});
export { handler as GET, handler as POST };
创建 trpc
的服务端程序,并且定一个 test
的子路由,其中包括了一个 foo
函数将用户的调用返回。
// server/api/root.ts
import { createCallerFactory, createTRPCRouter, publicProcedure } from "@/server/api/trpc";
import { z } from 'zod'
export const appRouter = createTRPCRouter({
test: createTRPCRouter({
foo:publicProcedure.input(z.object({ test: z.string() })).query(({ input }) => {
return {
input
}
})
})
});
export type AppRouter = typeof appRouter;
export const createCaller = createCallerFactory(appRouter);
创建 tRPC
的上下对象,并且集成 next-auth
,如果用户登录成功,那么 tRPC
的上下文当中就会包含用的回话信息。
定义了一个 publicProcedure
用于无需登录验证的 trpc
路由定义,protectedProcedure
用于需要登录验证码的 trpc
路由定义。
// server/api/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { ZodError } from "zod";
import { auth } from "@/server/auth";
import { db } from "@/server/db";
export const createTRPCContext = async (opts: { headers: Headers }) => {
const session = await auth();
return {
db,
session,
...opts,
};
};
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createCallerFactory = t.createCallerFactory;
export const createTRPCRouter = t.router;
const timingMiddleware = t.middleware(async ({ next, path }) => {
const start = Date.now();
if (t._config.isDev) {
const waitMs = Math.floor(Math.random() * 400) + 100;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
const result = await next();
const end = Date.now();
console.log(`[TRPC] ${path} took ${end - start}ms to execute`);
return result;
});
export const publicProcedure = t.procedure.use(timingMiddleware);
export const protectedProcedure = t.procedure
.use(timingMiddleware)
.use(({ ctx, next }) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
session: { ...ctx.session, user: ctx.session.user },
},
});
});
定义了一个 tRPC
客户端,当我们在客户端组件当中调用 tRPC
路由函数时,需要使用到该函数创建客户端。
// trpc/query-client.ts
import {
defaultShouldDehydrateQuery,
QueryClient,
} from "@tanstack/react-query";
import SuperJSON from "superjson";
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
},
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === "pending",
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
},
});
使用上面的 createQueryClient
创建出来一个全局的客户端,并且提供一个上下文对象供后代组件使用。
// trpc/react.tsx
"use client";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { loggerLink, unstable_httpBatchStreamLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import { type AppRouter } from "@/server/api/root";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => {
if (typeof window === "undefined") {
return createQueryClient();
}
return (clientQueryClientSingleton ??= createQueryClient());
};
export const api = createTRPCReact<AppRouter>();
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;
export function TRPCReactProvider(props: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const [trpcClient] = useState(() =>
api.createClient({
links: [
loggerLink({
enabled: (op) =>
process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error),
}),
unstable_httpBatchStreamLink({
transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc",
headers: () => {
const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react");
return headers;
},
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<api.Provider client={trpcClient} queryClient={queryClient}>
{props.children}
</api.Provider>
</QueryClientProvider>
);
}
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
定义了一个服务端 tRPC
调用者,方便我们在 RSC (React Server Component / React 服务端组件) 之间相互调用。
// trpc/server.ts
import "server-only";
import { createHydrationHelpers } from "@trpc/react-query/rsc";
import { headers } from "next/headers";
import { cache } from "react";
import { createCaller, type AppRouter } from "@/server/api/root";
import { createTRPCContext } from "@/server/api/trpc";
import { createQueryClient } from "./query-client";
const createContext = cache(async () => {
const heads = new Headers(await headers());
heads.set("x-trpc-source", "rsc");
return createTRPCContext({
headers: heads,
});
});
const getQueryClient = cache(createQueryClient);
const caller = createCaller(createContext);
export const { trpc: api, HydrateClient } = createHydrationHelpers<AppRouter>(
caller,
getQueryClient
);
定义 next-auth
的配置文件,可以在当中添加自己应用的登录授权逻辑。
// server/auth/config.ts
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { type DefaultSession, type NextAuthConfig } from "next-auth";
import { db } from "@/server/db";
import {
accounts,
sessions,
users,
verificationTokens,
} from "@/server/db/schema";
declare module "next-auth" {
interface Session extends DefaultSession {
user: {
id: string;
} & DefaultSession["user"];
}
}
export const authConfig = {
providers: [],
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}),
callbacks: {
session: ({ session, user }) => ({
...session,
user: {
...session.user,
id: user.id,
},
}),
},
} satisfies NextAuthConfig;
创建 next-auth
的处理程序,供 API 处理接口使用。
// server/auth/index.ts
import NextAuth from "next-auth";
import { cache } from "react";
import { authConfig } from "./config";
const { auth: uncachedAuth, handlers, signIn, signOut } = NextAuth(authConfig);
const auth = cache(uncachedAuth);
export { auth, handlers, signIn, signOut };
使用 next-auth
创建出来的路由器来处理授权相关的请求。
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/server/auth";
export const { GET, POST } = handlers;
创建全局的数据库连接。
// server/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const globalForDb = globalThis as unknown as {
conn: postgres.Sql | undefined;
};
const conn = globalForDb.conn ?? postgres(process.env.DATABASE_URL);
if (process.env.NODE_ENV !== "production") globalForDb.conn = conn;
export const db = drizzle(conn, { schema });
定义 drizzle-orm
与 next-auth
集成所需要数据库表文件。
// server/db/schema.ts
import { relations, sql } from "drizzle-orm";
import {
index,
integer,
pgTableCreator,
primaryKey,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters";
export const createTable = pgTableCreator((name) => `fullstack_${name}`);
export const users = createTable("user", {
id: varchar("id", { length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: varchar("name", { length: 255 }),
email: varchar("email", { length: 255 }).notNull(),
emailVerified: timestamp("email_verified", {
mode: "date",
withTimezone: true,
}).default(sql`CURRENT_TIMESTAMP`),
image: varchar("image", { length: 255 }),
});
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
}));
export const accounts = createTable(
"account",
{
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id),
type: varchar("type", { length: 255 })
.$type<AdapterAccount["type"]>()
.notNull(),
provider: varchar("provider", { length: 255 }).notNull(),
providerAccountId: varchar("provider_account_id", {
length: 255,
}).notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: varchar("token_type", { length: 255 }),
scope: varchar("scope", { length: 255 }),
id_token: text("id_token"),
session_state: varchar("session_state", { length: 255 }),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
userIdIdx: index("account_user_id_idx").on(account.userId),
})
);
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, { fields: [accounts.userId], references: [users.id] }),
}));
export const sessions = createTable(
"session",
{
sessionToken: varchar("session_token", { length: 255 })
.notNull()
.primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id),
expires: timestamp("expires", {
mode: "date",
withTimezone: true,
}).notNull(),
},
(session) => ({
userIdIdx: index("session_user_id_idx").on(session.userId),
})
);
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const verificationTokens = createTable(
"verification_token",
{
identifier: varchar("identifier", { length: 255 }).notNull(),
token: varchar("token", { length: 255 }).notNull(),
expires: timestamp("expires", {
mode: "date",
withTimezone: true,
}).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
})
);
有了上面的文件,接下来我们将数据表同步到我们的数据库当中去,注意将 .env
文件当中的 DATABASE_URL
改成你自己的数据库连接配置。
使用下面的命令,同步表到数据库当中。
pnpm drizzle-kit push
如下所示,即可将我们定义的 schema
同步到数据当中。
数据库当中,同步创建出来 4 张表,如下图所示:
测试 tRPC 接口调用
修改 RootLayout
将我们的应用包在 TRPCReactProvider
当中。
服务端组件调用
修改 app
目录下的 page.tsx
添加下面的内容,注意导入的是 @/trpc/server
当中的 api
.
访问页面,可以看到我们已经能够的使用服务端组件调用了我们的 tTRPC
接口,并且渲染出来了内容。
客户端组件调用
创建客户端组件,如下图所示,注意是使用 @/trpc/react
当中的 api
。
修改 page.tsx
将客户端组件导入到其中,如下图所示:
访问页面,可以看到应该的将数据展示到了页面上,非常完美。
测试 GitHub 登录
测试过了上面的接口调用, 接下来我们给我们的应用接入 Github
登录的功能,需要提前在 github 上申请好 Client ID
和 Client secrets
, 如下图所示:
将 Client ID
和 Client secrets
分别设置到 .env
文件的 AUTH_GITHUB_ID
和 AUTH_GITHUB_SECRET
当中。
AUTH_GITHUB_ID="xxx"
AUTH_GITHUB_SECRET="xxx"
还需要将下面的 Authorization callback URL
设置成 http://localhost:3000/api/auth/callback/github
。
将 GitHub
填入 server/auth/config.ts
的 Providers
当中,如下图所示:
然后运行项目,访问 http://localhost:3000/api/auth/signin
, 可以看到如下的界面,点击按钮跳转到 Github
授权界面 。
授权完成之后,可以看到,我们已经成功登录了,页面上显示出来我们的 Github
账号信息,如下图所示:
并且数据当中也成功的创建了用户的 Github 账户信息。
恭喜你!通过这篇教程的学习,你已经成功创建了一套全栈开发的基础模板,掌握了从前端到后端的核心技能。现在,无论是构建用户界面、处理业务逻辑,还是管理数据库,你都能游刃有余地应对。可以说已经迈出了成为全栈工程师的重要一步!