├── .eslintrc.json ├── diagram.png ├── src ├── public │ ├── favicon.ico │ └── vercel.svg ├── utils │ ├── trpc.ts │ ├── base64.ts │ ├── jwt.ts │ ├── prisma.ts │ └── mailer.ts ├── constants.ts ├── server │ ├── createRouter.ts │ ├── route │ │ ├── app.router.ts │ │ ├── post.router.ts │ │ └── user.router.ts │ └── createContext.ts ├── schema │ ├── post.schema.ts │ └── user.schema.ts ├── pages │ ├── api │ │ └── trpc │ │ │ └── [trpc].ts │ ├── login.tsx │ ├── posts │ │ ├── index.tsx │ │ ├── [postId].tsx │ │ └── new.tsx │ ├── index.tsx │ ├── register.tsx │ └── _app.tsx ├── context │ └── user.context.tsx ├── styles │ ├── globals.css │ └── Home.module.css └── components │ └── LoginForm.tsx ├── next.config.js ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20220626043356_user │ │ └── migration.sql │ ├── 20220626060723_login_token │ │ └── migration.sql │ └── 20220626093954_add_posts │ │ └── migration.sql └── schema.prisma ├── next-env.d.ts ├── NOTES.md ├── .env ├── .gitignore ├── tsconfig.json ├── package.json ├── README.md └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomDoesTech/trpc-tutorial/HEAD/diagram.png -------------------------------------------------------------------------------- /src/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TomDoesTech/trpc-tutorial/HEAD/src/public/favicon.ico -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /src/utils/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createReactQueryHooks } from '@trpc/react' 2 | import { AppRouter } from '../server/route/app.router' 3 | 4 | export const trpc = createReactQueryHooks() 5 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL 2 | ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` 3 | : 'http://localhost:3000' 4 | 5 | export const url = `${baseUrl}/api/trpc` 6 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /src/utils/base64.ts: -------------------------------------------------------------------------------- 1 | export function encode(data: string) { 2 | return Buffer.from(data, 'utf-8').toString('base64') 3 | } 4 | 5 | export function decode(data: string) { 6 | return Buffer.from(data, 'base64').toString('utf-8') 7 | } 8 | -------------------------------------------------------------------------------- /src/server/createRouter.ts: -------------------------------------------------------------------------------- 1 | import { router } from '@trpc/server' 2 | import superjson from 'superjson' 3 | import { Context } from './createContext' 4 | 5 | export function createRouter() { 6 | return router().transformer(superjson) 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | const SECRET = process.env.SECRET || 'changme' 4 | 5 | export function signJwt(data: object) { 6 | return jwt.sign(data, SECRET) 7 | } 8 | 9 | export function verifyJwt(token: string) { 10 | return jwt.verify(token, SECRET) as T 11 | } 12 | -------------------------------------------------------------------------------- /src/server/route/app.router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter } from '../createRouter' 2 | import { postRouter } from './post.router' 3 | import { userRouter } from './user.router' 4 | 5 | export const appRouter = createRouter() 6 | .merge('users.', userRouter) 7 | .merge('posts.', postRouter) 8 | 9 | export type AppRouter = typeof appRouter 10 | -------------------------------------------------------------------------------- /src/schema/post.schema.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | export const createPostSchema = z.object({ 4 | title: z.string().max(256, 'Max title length is 356'), 5 | body: z.string().min(10), 6 | }) 7 | 8 | export type CreatePostInput = z.TypeOf 9 | 10 | export const getSinglePostSchema = z.object({ 11 | postId: z.string().uuid(), 12 | }) 13 | -------------------------------------------------------------------------------- /src/utils/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | declare global { 4 | var prisma: PrismaClient | undefined 5 | } 6 | 7 | export const prisma = global.prisma || new PrismaClient() 8 | 9 | if (process.env.NODE_ENV !== 'production') { 10 | global.prisma = prisma 11 | } 12 | 13 | // warn(prisma-client) There are already 10 instances of Prisma Client actively running. 14 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | ## Bootstrap application 2 | `yarn create next-app trpc-tutorial-pre --typescript` 3 | 4 | ## Install dependencies 5 | `yarn add @trpc/client @trpc/server @trpc/react @trpc/next zod react-query superjson jotai @prisma/client react-hook-form jsonwebtoken cookie nodemailer` 6 | 7 | `yarn add @types/jsonwebtoken @types/cookie @types/nodemailer -D` 8 | 9 | ## Prisma 10 | `npx prisma init` 11 | 12 | `npx prisma migrate dev --name` -------------------------------------------------------------------------------- /prisma/migrations/20220626043356_user/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "email" TEXT NOT NULL, 5 | "name" TEXT NOT NULL, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" TIMESTAMP(3) NOT NULL 8 | ); 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "User_id_key" ON "User"("id"); 12 | 13 | -- CreateIndex 14 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 15 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="postgresql://postgres:changeme@localhost:5432/trpc-tut?schema=public" -------------------------------------------------------------------------------- /src/pages/api/trpc/[trpc].ts: -------------------------------------------------------------------------------- 1 | import * as trpcNext from '@trpc/server/adapters/next' 2 | import { createContext } from '../../../server/createContext' 3 | import { appRouter } from '../../../server/route/app.router' 4 | 5 | export default trpcNext.createNextApiHandler({ 6 | router: appRouter, 7 | createContext, 8 | onError({ error }) { 9 | if (error.code === 'INTERNAL_SERVER_ERROR') { 10 | console.error('Something went wrong', error) 11 | } else { 12 | console.error(error) 13 | } 14 | }, 15 | }) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /prisma/migrations/20220626060723_login_token/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "LoginToken" ( 3 | "id" TEXT NOT NULL, 4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 5 | "userId" TEXT NOT NULL, 6 | "redirect" TEXT NOT NULL DEFAULT E'/' 7 | ); 8 | 9 | -- CreateIndex 10 | CREATE UNIQUE INDEX "LoginToken_id_key" ON "LoginToken"("id"); 11 | 12 | -- AddForeignKey 13 | ALTER TABLE "LoginToken" ADD CONSTRAINT "LoginToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 14 | -------------------------------------------------------------------------------- /src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { useRouter } from 'next/router' 3 | import { useState } from 'react' 4 | import { useForm } from 'react-hook-form' 5 | import dynamic from 'next/dynamic' 6 | import { CreateUserInput } from '../schema/user.schema' 7 | import { trpc } from '../utils/trpc' 8 | 9 | const LoginForm = dynamic(() => import('../components/LoginForm'), { 10 | ssr: false, 11 | }) 12 | 13 | function LoginPage() { 14 | return ( 15 |
16 | 17 |
18 | ) 19 | } 20 | 21 | export default LoginPage 22 | -------------------------------------------------------------------------------- /prisma/migrations/20220626093954_add_posts/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Post" ( 3 | "id" TEXT NOT NULL, 4 | "title" TEXT NOT NULL, 5 | "body" TEXT NOT NULL, 6 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 7 | "updatedAt" TIMESTAMP(3) NOT NULL, 8 | "userId" TEXT NOT NULL 9 | ); 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "Post_id_key" ON "Post"("id"); 13 | 14 | -- AddForeignKey 15 | ALTER TABLE "Post" ADD CONSTRAINT "Post_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/posts/index.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { trpc } from '../../utils/trpc' 3 | 4 | function PostListingPage() { 5 | const { data, isLoading } = trpc.useQuery(['posts.posts']) 6 | 7 | if (isLoading) { 8 | return

Loading...

9 | } 10 | 11 | return ( 12 |
13 | {data?.map((post) => { 14 | return ( 15 |
16 |

{post.title}

17 | Read post 18 |
19 | ) 20 | })} 21 |
22 | ) 23 | } 24 | 25 | export default PostListingPage 26 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next' 2 | import Head from 'next/head' 3 | import Image from 'next/image' 4 | import Link from 'next/link' 5 | import LoginForm from '../components/LoginForm' 6 | import { useUserContext } from '../context/user.context' 7 | import styles from '../styles/Home.module.css' 8 | import { trpc } from '../utils/trpc' 9 | 10 | const Home: NextPage = () => { 11 | const user = useUserContext() 12 | 13 | if (!user) { 14 | return 15 | } 16 | 17 | return ( 18 |
19 | Create post 20 |
21 | ) 22 | } 23 | 24 | export default Home 25 | -------------------------------------------------------------------------------- /src/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | export const createUserSchema = z.object({ 4 | name: z.string(), 5 | email: z.string().email(), 6 | }) 7 | 8 | export const createUserOutputSchema = z.object({ 9 | name: z.string(), 10 | email: z.string().email(), 11 | }) 12 | 13 | export type CreateUserInput = z.TypeOf 14 | 15 | export const requestOtpSchema = z.object({ 16 | email: z.string().email(), 17 | redirect: z.string().default('/'), 18 | }) 19 | 20 | export type requestOtpInput = z.TypeOf 21 | 22 | export const verifyOtpSchema = z.object({ 23 | hash: z.string(), 24 | }) 25 | -------------------------------------------------------------------------------- /src/pages/posts/[postId].tsx: -------------------------------------------------------------------------------- 1 | import Error from 'next/error' 2 | import { useRouter } from 'next/router' 3 | import { trpc } from '../../utils/trpc' 4 | 5 | function SinglePostPage() { 6 | const router = useRouter() 7 | 8 | const postId = router.query.postId as string 9 | 10 | const { data, isLoading } = trpc.useQuery(['posts.single-post', { postId }]) 11 | 12 | if (isLoading) { 13 | return

Loading posts...

14 | } 15 | 16 | if (!data) { 17 | return 18 | } 19 | 20 | return ( 21 |
22 |

{data?.title}

23 |

{data?.body}

24 |
25 | ) 26 | } 27 | 28 | export default SinglePostPage 29 | -------------------------------------------------------------------------------- /src/utils/mailer.ts: -------------------------------------------------------------------------------- 1 | import nodemailer from 'nodemailer' 2 | 3 | export async function sendLoginEmail({ 4 | email, 5 | url, 6 | token, 7 | }: { 8 | email: string 9 | url: string 10 | token: string 11 | }) { 12 | const testAccount = await nodemailer.createTestAccount() 13 | 14 | const transporter = nodemailer.createTransport({ 15 | host: 'smtp.ethereal.email', 16 | port: 587, 17 | secure: false, 18 | auth: { 19 | user: testAccount.user, 20 | pass: testAccount.pass, 21 | }, 22 | }) 23 | 24 | const info = await transporter.sendMail({ 25 | from: '"Jane Doe" ', 26 | to: email, 27 | subject: 'Login to your account', 28 | html: `Login by clicking HERE`, 29 | }) 30 | 31 | console.log(`Preview URL: ${nodemailer.getTestMessageUrl(info)}`) 32 | } 33 | -------------------------------------------------------------------------------- /src/context/user.context.tsx: -------------------------------------------------------------------------------- 1 | import { inferProcedureOutput } from '@trpc/server' 2 | import React, { createContext, useContext } from 'react' 3 | import { AppRouter } from '../server/route/app.router' 4 | 5 | type TQuery = keyof AppRouter['_def']['queries'] 6 | 7 | type InferQueryOutput = inferProcedureOutput< 8 | AppRouter['_def']['queries'][TRouteKey] 9 | > 10 | 11 | const UserContext = createContext>(null) 12 | 13 | function UserContextProvider({ 14 | children, 15 | value, 16 | }: { 17 | children: React.ReactNode 18 | value: InferQueryOutput<'users.me'> | undefined 19 | }) { 20 | return ( 21 | 22 | {children} 23 | 24 | ) 25 | } 26 | 27 | const useUserContext = () => useContext(UserContext) 28 | 29 | export { useUserContext, UserContextProvider } 30 | -------------------------------------------------------------------------------- /src/server/createContext.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | import { verifyJwt } from '../utils/jwt' 3 | import { prisma } from '../utils/prisma' 4 | 5 | interface CtxUser { 6 | id: string 7 | email: string 8 | name: string 9 | iat: string 10 | exp: number 11 | } 12 | 13 | function getUserFromRequest(req: NextApiRequest) { 14 | const token = req.cookies.token 15 | 16 | if (token) { 17 | try { 18 | const verified = verifyJwt(token) 19 | return verified 20 | } catch (e) { 21 | return null 22 | } 23 | } 24 | 25 | return null 26 | } 27 | 28 | export function createContext({ 29 | req, 30 | res, 31 | }: { 32 | req: NextApiRequest 33 | res: NextApiResponse 34 | }) { 35 | const user = getUserFromRequest(req) 36 | 37 | return { req, res, prisma, user } 38 | } 39 | 40 | export type Context = ReturnType 41 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | html { 2 | font-size: 16px; 3 | } 4 | html, 5 | body { 6 | padding: 1.5rem; 7 | margin: 0; 8 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 9 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 10 | } 11 | main { 12 | max-width: 1024px; 13 | margin: 0 auto; 14 | } 15 | 16 | a { 17 | color: inherit; 18 | text-decoration: none; 19 | } 20 | 21 | * { 22 | box-sizing: border-box; 23 | } 24 | 25 | article { 26 | border: solid 1px black; 27 | padding: 1rem; 28 | } 29 | 30 | article > p { 31 | font-size: 1.5rem; 32 | font-weight: bold; 33 | } 34 | 35 | a { 36 | text-decoration: underline; 37 | } 38 | 39 | input, 40 | textarea { 41 | font-size: 1.5rem; 42 | padding: 1rem; 43 | } 44 | 45 | button { 46 | background-color: blueviolet; 47 | color: white; 48 | font-size: 1.5rem; 49 | padding: 1.5rem; 50 | border: none; 51 | border-radius: 4px; 52 | cursor: pointer; 53 | } 54 | 55 | form { 56 | display: flex; 57 | flex-direction: column; 58 | } 59 | -------------------------------------------------------------------------------- /src/pages/posts/new.tsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from 'next/router' 2 | import { useForm } from 'react-hook-form' 3 | import { CreatePostInput } from '../../schema/post.schema' 4 | import { trpc } from '../../utils/trpc' 5 | 6 | function CreatePostPage() { 7 | const { handleSubmit, register } = useForm() 8 | const router = useRouter() 9 | 10 | const { mutate, error } = trpc.useMutation(['posts.create-post'], { 11 | onSuccess: ({ id }) => { 12 | router.push(`/posts/${id}`) 13 | }, 14 | }) 15 | 16 | function onSubmit(values: CreatePostInput) { 17 | mutate(values) 18 | } 19 | 20 | return ( 21 |
22 | {error && error.message} 23 | 24 |

Create posts

25 | 26 | 27 |
28 |