Привет! Сегодня мы будем создавать приложение с помощью стека T3. Мы собираемся создать гостевую книгу, вдохновленную гостевой книгой Ли Робинсона. Давайте приступим к работе!
Начало работы
Давайте создадим начальный проект с помощью команды create-t3-app
!
npx create-t3-app@latest
Мы будем использовать все части стека.
Давайте также настроим базу данных Postgres на Railway. Railway позволяет очень просто и быстро настроить базу данных.
Перейдите на Railway и войдите на GitHub, если вы еще этого не сделали. Теперь нажмите на New Project
.
Теперь укажите Postgres.
Это очень просто. Скопируйте строку подключения из вкладки Connect
.
Давайте начнем кодировать! Откройте проект в вашем любимом редакторе кода.
В нем много папок, но не перегружайтесь. Вот основной обзор.
Откройте файл .env
и вставьте строку подключения в DATABASE_URL
.
Вы заметите, что у нас настроен Discord OAuth с помощью next-auth
, поэтому нам также нужны 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
в модели Account
в prisma/schema.prisma
. Все модели, которые вы видите в схеме, необходимы для работы Next Auth.
Давайте протолкнем эту схему в нашу базу данных 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
, вы увидите, что у нас уже настроен Discord OAuth с помощью Next Auth. Здесь вы можете добавить другие OAuth-провайдеры, такие как Google, Twitter и т.д.
Теперь давайте создадим кнопку, которая позволит пользователям войти в систему с помощью Discord. Мы можем использовать функцию signIn()
из Next Auth.
// src/pages/index.tsx
import { signIn } from "next-auth/react";
const Home = () => {
return (
<main>
<h1>Guestbook</h1>
<button onClick={() => signIn("discord")}>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>
{session ? (
<div>
<p>
hi {session.user?.name}
</p>
<button onClick={() => signOut()}>Logout</button>
</div>
) : (
<div>
<button onClick={() => signIn("discord")}>Login with Discord</button>
</div>
)}
</main>
);
};
export default Home;
Отлично! Теперь у нас работает аутентификация. Next Auth действительно делает все до глупости просто.
Бэкенд
Давайте теперь поработаем над бэкендом. Мы будем использовать tRPC для нашего уровня API и Prisma для подключения и запросов к базе данных.
Нам придется изменить схему Prisma и добавить модель Guestbook
. Каждое сообщение в гостевой книге будет иметь имя и сообщение. Вот как будет выглядеть модель.
// prisma/schema.prisma
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. Идем дальше и удаляем protected-example-router.ts
и protected-router.ts
и example.ts
в src/server/router
. Сначала мы определим мутацию для отправки сообщений в нашу базу данных.
// src/server/router/guestbook.ts
import { z } from "zod";
import { createRouter } from "./context";
export const guestbookRouter = createRouter().mutation("postMessage", {
input: z.object({
name: z.string(),
message: z.string(),
}),
async resolve({ ctx, input }) {
try {
await ctx.prisma.guestbook.create({
data: {
name: input.name,
message: input.message,
},
});
} catch (error) {
console.log(error);
}
},
});
Здесь мы имеем мутацию tRPC, которая использует zod для проверки ввода и имеет функцию resolve, которая выполняет простой запрос prisma для создания новой строки в таблице Guestbook
.
Работа с prisma — это совершенно замечательный пример. Автозаполнение и безопасность типов просто потрясающие.
Мы также хотим, чтобы эта мутация была защищена. Здесь мы можем использовать промежуточные модули tRPC. Если вы посмотрите на src/server/context.ts
, мы используем [unstable_getServerSession
] из Next Auth, который дает нам доступ к сессии на сервере. Мы передаем ее в наш контекст tRPC. Мы можем использовать эту сессию, чтобы сделать нашу мутацию защищенной.
// src/server/router/guestbook.ts
export const guestbookRouter = createRouter()
.middleware(async ({ ctx, next }) => {
// Any queries or mutations after this middleware will
// raise an error unless there is a current session
if (!ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next();
})
.mutation("postMessage", {
// ...
Далее напишем запрос для получения всех сообщений в гостевой книге, этот запрос тоже будет довольно простым.
Его не нужно защищать, поэтому мы можем разместить его над промежуточным ПО.
// src/server/router/guestbook.ts
export const guestbookRouter = createRouter()
.query("getAll", {
async resolve({ ctx }) {
try {
return await ctx.prisma.guestbook.findMany({
select: {
name: true,
message: true,
},
orderBy: {
createdAt: "desc",
},
});
} catch (error) {
console.log("error", error);
}
},
})
.middleware(async ({ ctx, next }) => {
// ...
Здесь мы получаем только имя и сообщение из всех строк модели Guestbook
и сортируем их в порядке убывания по полю createdAt
.
Теперь объедините этот маршрутизатор в основной appRouter
.
// src/server/router/index.ts
import superjson from "superjson";
import { createRouter } from "./context";
import { guestbookRouter } from "./guestbook";
export const appRouter = createRouter()
.transformer(superjson)
.merge("guestbook.", guestbookRouter);
// export type definition of API
export type AppRouter = typeof appRouter;
На этом мы практически закончили с бэкендом. Теперь давайте поработаем над пользовательским интерфейсом.
Фронтенд
Давайте сначала выровняем все по центру.
// 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">
{session ? (
<div>
<p>hi {session.user?.name}</p>
<button onClick={() => signOut()}>Logout</button>
</div>
) : (
<div>
<button onClick={() => signIn("discord")}>
Login with Discord
</button>
</div>
)}
</div>
</main>
);
};
export default Home;
Я также сделал заголовок больше и добавил немного прокладок между элементами.
Давайте используем наш tRPC-запрос, чтобы получить все сообщения для гостевой книги в базе данных. Но сейчас у нас нет никаких данных. Мы можем использовать Prisma Studio для получения некоторых данных вручную.
npx prisma studio
Он автоматически откроется на http://localhost:5555
. Перейдите к таблице Guestbook
и добавьте кучу записей, как здесь.
Теперь, когда у нас есть данные, мы можем использовать запрос и отобразить данные. Для этого мы можем использовать обертку tRPC react-query. Давайте создадим для этого компонент в src/pages/index.tsx
.
// src/pages/index.tsx
import { trpc } from "../utils/trpc";
const Messages = () => {
const { data: messages, isLoading } = trpc.useQuery(["guestbook.getAll"]);
if (isLoading) return <div>Fetching messages...</div>;
return (
<div className="flex flex-col gap-4">
{messages?.map((msg, index) => {
return (
<div key={index}>
<p>{msg.message}</p>
<span>- {msg.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">
{session ? (
<div>
<p>hi {session.user?.name}</p>
<button onClick={() => signOut()}>Logout</button>
<div className="pt-10">
<Messages />
</div>
</div>
) : (
<div>
<button onClick={() => signIn("discord")}>
Login with Discord
</button>
<div className="pt-10" />
<Messages />
</div>
)}
</div>
</main>
Теперь давайте создадим форму и используем в ней нашу мутацию tRPC.
// src/pages/index.tsx
const Home = () => {
const { data: session, status } = useSession();
const [message, setMessage] = useState("");
const postMessage = trpc.useMutation("guestbook.postMessage");
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">
{session ? (
<div>
<p>hi {session.user?.name}</p>
<button onClick={() => signOut()}>Logout</button>
<div className="pt-6">
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
postMessage.mutate({
name: session.user?.name as string,
message,
});
setMessage("");
}}
>
<input
type="text"
value={message}
placeholder="Your message..."
maxLength={100}
onChange={(event) => setMessage(event.target.value)}
className="px-4 py-2 rounded-md border-2 border-zinc-800 bg-neutral-900 focus:outline-none"
/>
<button
type="submit"
className="p-2 rounded-md border-2 border-zinc-800 focus:outline-none"
>
Submit
</button>
</form>
</div>
<div className="pt-10">
<Messages />
</div>
</div>
) : (
<div>
<button onClick={() => signIn("discord")}>
Login with Discord
</button>
<div className="pt-10" />
<Messages />
</div>
)}
</div>
</main>
);
};
Здесь у нас есть форма, и мы используем useMutation()
для отправки данных в базу данных. Но вы заметите одну проблему. Когда мы нажимаем на кнопку submit, она действительно отправляет сообщение в базу данных, но пользователь не получает никакого немедленного отклика. Только при обновлении страницы пользователь может увидеть новое сообщение.
Для этого мы можем использовать оптимистичное обновление пользовательского интерфейса! react-query
позволяет сделать это очень просто. Нам просто нужно добавить кое-что в наш хук useMutation()
.
// src/pages/index.tsx
const ctx = trpc.useContext();
const postMessage = trpc.useMutation("guestbook.postMessage", {
onMutate: () => {
ctx.cancelQuery(["guestbook.getAll"]);
let optimisticUpdate = ctx.getQueryData(["guestbook.getAll"]);
if (optimisticUpdate) {
ctx.setQueryData(["guestbook.getAll"], optimisticUpdate);
}
},
onSettled: () => {
ctx.invalidateQueries(["guestbook.getAll"]);
},
});
Код практически не требует пояснений. Вы можете прочитать больше об оптимистичных обновлениях с помощью react-query
здесь.
Мы практически закончили с частью кодирования! Это было довольно просто, не так ли? Стек T3 позволяет очень легко и быстро создавать веб-приложения с полным стеком. Теперь давайте развернем нашу гостевую книгу.
Развертывание
Для развертывания мы будем использовать Vercel. Vercel позволяет очень легко развертывать приложения NextJS, именно они создали NextJS.
Сначала разместите свой код в репозитории GitHub. Перейдите в Vercel и зарегистрируйтесь на GitHub, если вы еще этого не сделали.
Затем нажмите на New Project
и импортируйте только что созданный репозиторий.
Теперь нам нужно добавить переменные окружения, поэтому скопируйте и вставьте все переменные окружения в Vercel. После того как вы это сделаете, нажмите Deploy
.
Добавьте пользовательский домен, если он у вас есть, и все готово! Поздравляем!
Весь код можно найти здесь. Вы можете посетить сайт по адресу guestbook.nxl.sh.
Кредиты
- Ли Робинсону за идею гостевой книги.
- JAR и Krish за вычитку.