diff --git a/README.md b/README.md index 63ed8969..df09c3f8 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ - [x] Playwright E2E tests. - [x] github actions. - [x] SEO (dub.co is a great reference) for example robots.ts file. +- [x] tRPC api with tanstack query. - [ ] Shadcn UI. - [ ] Slate/Plate.js editor. -- [ ] tRPC api with tanstack query. - [ ] New & Refined README.md. - [ ] Add a `CONTRIBUTING.md` file. @@ -18,7 +18,6 @@ - [x] Neon Database / Drizzle orm. - [x] Upstash redis - [x] Clerk authentication. -- [ ] Arcjet. - [ ] Resend & React email. - [ ] Stripe payment. - [ ] Sentry. diff --git a/bun.lockb b/bun.lockb index 529f0bd1..132832e5 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cspell.config.yaml b/cspell.config.yaml index 1c127a56..b1c0aa2a 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -36,6 +36,8 @@ words: - Shadcn - Signin - Signup + - sslmode + - superjson - tailwindcss - tanstack - trpc @@ -44,4 +46,3 @@ words: - typecheck - Uploadthing - Upstash - - sslmode diff --git a/package.json b/package.json index 349cc34f..ce4bdced 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,10 @@ "@radix-ui/react-navigation-menu": "^1.1.4", "@radix-ui/react-slot": "^1.0.2", "@t3-oss/env-nextjs": "^0.10.1", + "@tanstack/react-query": "^5.39.0", + "@trpc/client": "^11.0.0-rc.374", + "@trpc/react-query": "^11.0.0-rc.374", + "@trpc/server": "^11.0.0-rc.374", "@upstash/redis": "^1.31.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -72,6 +76,8 @@ "next-themes": "^0.3.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "server-only": "^0.0.1", + "superjson": "^2.2.1", "tailwind-merge": "^2.3.0", "zod": "^3.23.8" }, diff --git a/src/app/(dashboard)/app/page.tsx b/src/app/(dashboard)/app/page.tsx index ba313f76..37145999 100644 --- a/src/app/(dashboard)/app/page.tsx +++ b/src/app/(dashboard)/app/page.tsx @@ -1,14 +1,18 @@ +import { api } from '@/lib/trpc/server'; import { SignOutButton } from '@clerk/nextjs'; /** * This is the main page for the dashboard. * @returns A Next.js RSC page component. */ -export default function DashboardHome() { +export default async function DashboardHome() { + const { message } = await api.greeting.protected(); + return (

Hello World

+

{message}

); } diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 00000000..71570b8d --- /dev/null +++ b/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,29 @@ +import type { NextRequest } from 'next/server'; + +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; + +import { env } from '@/env'; +import { appRouter } from '@/server'; +import { createTRPCContext } from '@/server/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: ({ path, error }) => { + env.NODE_ENV === 'development' && + console.error( + `❌ tRPC failed on ${path ?? ''}: ${error.message}`, + ); + }, + }); + +export { handler as GET, handler as POST }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 13a9f52d..9c76917c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -12,6 +12,7 @@ import { ThemeProvider } from 'next-themes'; import type { PropsWithChildren } from 'react'; import { constructMetadata } from '@/lib/utils'; +import { TRPCReactProvider } from '@/lib/trpc/react'; export const metadata: Metadata = constructMetadata(); @@ -34,7 +35,7 @@ export default function RootLayout({ children }: PropsWithChildren) { > - {children} + {children} diff --git a/src/lib/trpc/react.tsx b/src/lib/trpc/react.tsx new file mode 100644 index 00000000..2d037c76 --- /dev/null +++ b/src/lib/trpc/react.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useState } from 'react'; + +import { QueryClient, QueryClientProvider } 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 superjson from 'superjson'; + +import { getBaseUrl } from '@/lib/utils'; +import { type AppRouter } from '@/server'; + +const createQueryClient = () => new QueryClient(); + +let clientQueryClientSingleton: QueryClient | undefined = undefined; +const getQueryClient = () => { + if (typeof window === 'undefined') { + return createQueryClient(); + } + return (clientQueryClientSingleton ??= createQueryClient()); +}; + +export const api = createTRPCReact(); + +export type RouterInputs = inferRouterInputs; +export type RouterOutputs = inferRouterOutputs; + +/** + * The TRPC React Provider is a component that initializes trpc and react query. + * @param props Props for the TRPC React Provider. + * @param props.children The children of the TRPC React Provider, which would be + * the entire page/layout in most cases. + * @returns React Provider component that initializes trpc and react query + * integration for the client. + */ +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 ( + + + {props.children} + + + ); +} diff --git a/src/lib/trpc/server.ts b/src/lib/trpc/server.ts new file mode 100644 index 00000000..31d70046 --- /dev/null +++ b/src/lib/trpc/server.ts @@ -0,0 +1,18 @@ +import 'server-only'; + +import { cache } from 'react'; +import { headers } from 'next/headers'; + +import { createCaller } from '@/server'; +import { createTRPCContext } from '@/server/trpc'; + +const createContext = cache(async () => { + const heads = new Headers(headers()); + heads.set('x-trpc-source', 'rsc'); + + return createTRPCContext({ + headers: heads, + }); +}); + +export const api = createCaller(createContext); diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 00000000..b3d6d3f6 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,10 @@ +import { greetingRouter } from './routers/greeting'; +import { createCallerFactory, createRouter } from './trpc'; + +export const appRouter = createRouter({ + greeting: greetingRouter, +}); + +export type AppRouter = typeof appRouter; + +export const createCaller = createCallerFactory(appRouter); diff --git a/src/server/routers/greeting.ts b/src/server/routers/greeting.ts new file mode 100644 index 00000000..f37b4821 --- /dev/null +++ b/src/server/routers/greeting.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { createRouter, protectedProcedure } from '../trpc'; + +export const greetingRouter = createRouter({ + protected: protectedProcedure + .input(z.object({ name: z.string().optional() }).optional()) + .query(({ input, ctx }) => { + return { + message: `Hello, ${input?.name ?? ctx.session.firstName ?? ''}, I'm from the tRPC procedure!`, + }; + }), +}); diff --git a/src/server/trpc.ts b/src/server/trpc.ts new file mode 100644 index 00000000..e5249653 --- /dev/null +++ b/src/server/trpc.ts @@ -0,0 +1,46 @@ +import { currentUser } from '@clerk/nextjs/server'; +import { initTRPC, TRPCError } from '@trpc/server'; +import superjson from 'superjson'; +import { ZodError } from 'zod'; + +import { db } from '@/db'; + +export const createTRPCContext = async (opts: { headers: Headers }) => { + const session = await currentUser(); + + return { + db, + session, + ...opts, + }; +}; + +const t = initTRPC.context().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 createRouter = t.router; +export const publicProcedure = t.procedure; + +export const protectedProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.session) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + + return next({ + ctx: { + session: { ...ctx.session }, + }, + }); +});