diff --git a/bun.lockb b/bun.lockb index 747835e..ebf6923 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 58fa80e..61d917a 100644 --- a/package.json +++ b/package.json @@ -30,11 +30,12 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.1", "@t3-oss/env-nextjs": "^0.10.1", - "@tanstack/react-query": "^5.45.1", - "@trpc/client": "^10.45.2", - "@trpc/react-query": "^10.45.2", - "@trpc/server": "^10.45.2", + "@tanstack/react-query": "^5.53.1", + "@trpc/client": "^11.0.0-rc.490", + "@trpc/react-query": "^11.0.0-rc.490", + "@trpc/server": "^11.0.0-rc.490", "autoprefixer": "^10.4.19", + "client-only": "^0.0.1", "cmdk": "1.0.0", "country-flag-icons": "^1.5.12", "cva": "^1.0.0-beta.1", @@ -52,6 +53,7 @@ "reading-time": "^1.5.0", "server-only": "^0.0.1", "sharp": "^0.33.4", + "superjson": "^2.2.1", "tailwind-merge": "^2.5.2", "zod": "^3.23.8" }, diff --git a/src/app/api/data/[trpc]/route.ts b/src/app/api/data/[trpc]/route.ts new file mode 100644 index 0000000..754ffb8 --- /dev/null +++ b/src/app/api/data/[trpc]/route.ts @@ -0,0 +1,33 @@ +import { env } from '@/env'; +import { appRouter } from '@/server/api/root'; +import { createTRPCContext } from '@/server/api/trpc'; +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import type { NextRequest } from 'next/server'; + +/** + * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when + * handling a HTTP request (e.g. when you make requests from Client Components). + */ +const createContext = async (request: NextRequest) => { + return createTRPCContext({ + headers: request.headers, + }); +}; + +const handler = (request: NextRequest) => + fetchRequestHandler({ + endpoint: '/api/data', + req: request, + router: appRouter, + createContext: () => createContext(request), + onError: + env.NODE_ENV === 'development' + ? ({ path, error }) => { + console.error( + `❌ tRPC failed on ${path ?? ''}: ${error.message}`, + ); + } + : undefined, + }); + +export { handler as GET, handler as POST }; diff --git a/src/components/providers/RootProviders.tsx b/src/components/providers/RootProviders.tsx index 0f2502c..d4f5704 100644 --- a/src/components/providers/RootProviders.tsx +++ b/src/components/providers/RootProviders.tsx @@ -1,4 +1,5 @@ import { IntlErrorProvider } from '@/components/providers/IntlErrorProvider'; +import { TRPCProvider } from '@/components/providers/TRPCProvider'; import { ThemeProvider } from '@/components/providers/ThemeProvider'; type RootProvidersProps = { @@ -9,7 +10,9 @@ type RootProvidersProps = { function RootProviders({ children, locale }: RootProvidersProps) { return ( - {children} + + {children} + ); } diff --git a/src/components/providers/TRPCProvider.tsx b/src/components/providers/TRPCProvider.tsx new file mode 100644 index 0000000..45b6cad --- /dev/null +++ b/src/components/providers/TRPCProvider.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { env } from '@/env'; +import { api } from '@/lib/api/client'; +import { createQueryClient } from '@/lib/api/queryClient'; +import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { loggerLink, unstable_httpBatchStreamLink } from '@trpc/client'; +import { useState } from 'react'; +import SuperJSON from 'superjson'; + +let clientQueryClientSingleton: QueryClient | undefined = undefined; +const getQueryClient = () => { + if (typeof window === 'undefined') { + // Server: always make a new query client + return createQueryClient(); + } + // Browser: use singleton pattern to keep the same query client + if (!clientQueryClientSingleton) { + clientQueryClientSingleton = createQueryClient(); + } + return clientQueryClientSingleton; +}; + +function TRPCProvider(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: `${env.SITE_URL}/api/data`, + headers: () => { + const headers = new Headers(); + headers.set('x-trpc-source', 'nextjs-react'); + return headers; + }, + }), + ], + }), + ); + + return ( + + + {props.children} + + + ); +} + +export { TRPCProvider }; diff --git a/src/components/providers/ThemeProvider.tsx b/src/components/providers/ThemeProvider.tsx index 6c06483..39919b9 100644 --- a/src/components/providers/ThemeProvider.tsx +++ b/src/components/providers/ThemeProvider.tsx @@ -1,18 +1,18 @@ 'use client'; -import { ThemeProvider } from 'next-themes'; +import { ThemeProvider as NextThemeProvider } from 'next-themes'; -function NextThemeProvider({ children }: { children: React.ReactNode }) { +function ThemeProvider({ children }: { children: React.ReactNode }) { return ( - {children} - + ); } -export { NextThemeProvider as ThemeProvider }; +export { ThemeProvider }; diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..df56eaf --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,23 @@ +'use client'; + +import type { AppRouter } from '@/server/api/root'; +import { createTRPCReact } from '@trpc/react-query'; +import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; + +const api = createTRPCReact(); + +/** + * Inference helper for inputs. + * + * @example type HelloInput = RouterInputs['example']['hello'] + */ +type RouterInputs = inferRouterInputs; + +/** + * Inference helper for outputs. + * + * @example type HelloOutput = RouterOutputs['example']['hello'] + */ +type RouterOutputs = inferRouterOutputs; + +export { api, type RouterInputs, type RouterOutputs }; diff --git a/src/lib/api/queryClient.ts b/src/lib/api/queryClient.ts new file mode 100644 index 0000000..b121db5 --- /dev/null +++ b/src/lib/api/queryClient.ts @@ -0,0 +1,28 @@ +import { + QueryClient, + defaultShouldDehydrateQuery, +} from '@tanstack/react-query'; +import SuperJSON from 'superjson'; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + // With SSR, we usually want to set some default staleTime + // above 0 to avoid refetching immediately on the client + staleTime: 30 * 1000, + }, + dehydrate: { + serializeData: SuperJSON.serialize, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending', + }, + hydrate: { + deserializeData: SuperJSON.deserialize, + }, + }, + }); +} + +export { createQueryClient }; diff --git a/src/lib/api/server.ts b/src/lib/api/server.ts new file mode 100644 index 0000000..6b37eda --- /dev/null +++ b/src/lib/api/server.ts @@ -0,0 +1,31 @@ +import 'server-only'; + +import { createQueryClient } from '@/lib/api/queryClient'; +import { type AppRouter, createCaller } from '@/server/api/root'; +import { createTRPCContext } from '@/server/api/trpc'; +import { createHydrationHelpers } from '@trpc/react-query/rsc'; +import { headers } from 'next/headers'; +import { cache } from 'react'; + +/** + * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when + * handling a tRPC call from a React Server Component. + */ +const createContext = cache(() => { + const heads = new Headers(headers()); + heads.set('x-trpc-source', 'rsc'); + + return createTRPCContext({ + headers: heads, + }); +}); + +const getQueryClient = cache(createQueryClient); +const caller = createCaller(createContext); + +const { trpc: api, HydrateClient } = createHydrationHelpers( + caller, + getQueryClient, +); + +export { api, HydrateClient }; diff --git a/src/server/api/root.ts b/src/server/api/root.ts new file mode 100644 index 0000000..40ea9cb --- /dev/null +++ b/src/server/api/root.ts @@ -0,0 +1,25 @@ +import { testRouter } from '@/server/api/routers/test'; +import { createCallerFactory, createTRPCRouter } from '@/server/api/trpc'; + +/** + * This is the primary router for your server. + * + * All routers added in /api/routers should be manually added here. + */ +const appRouter = createTRPCRouter({ + test: testRouter, +}); + +// export type definition of API +type AppRouter = typeof appRouter; + +/** + * Create a server-side caller for the tRPC API. + * @example + * const trpc = createCaller(createContext); + * const res = await trpc.post.all(); + * ^? Post[] + */ +const createCaller = createCallerFactory(appRouter); + +export { appRouter, createCaller, type AppRouter }; diff --git a/src/server/api/routers/test.ts b/src/server/api/routers/test.ts new file mode 100644 index 0000000..41a3340 --- /dev/null +++ b/src/server/api/routers/test.ts @@ -0,0 +1,14 @@ +import { createTRPCRouter, publicProcedure } from '@/server/api/trpc'; +import { posts } from '@/server/db/schema'; + +const testRouter = createTRPCRouter({ + getLatest: publicProcedure.query(async ({ ctx }) => { + const post = await ctx.db.query.posts.findFirst({ + orderBy: (posts, { desc }) => [desc(posts.createdAt)], + }); + + return post ?? null; + }), +}); + +export { testRouter }; diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts new file mode 100644 index 0000000..db0f9d6 --- /dev/null +++ b/src/server/api/trpc.ts @@ -0,0 +1,111 @@ +import { auth } from '@/server/auth'; +import { db } from '@/server/db'; +import { s3 } from '@/server/s3'; +/** + * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: + * 1. You want to modify request context (see Part 1). + * 2. You want to create a new middleware or type of procedure (see Part 3). + * + * TL;DR - This is where all the tRPC server stuff is created and plugged in. The pieces you will + * need to use are documented accordingly near the end. + */ +import { initTRPC } from '@trpc/server'; +import superjson from 'superjson'; +import { ZodError } from 'zod'; + +/** + * 1. CONTEXT + * + * This section defines the "contexts" that are available in the backend API. + * + * These allow you to access things when processing a request, like the database, the session, etc. + * + * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each + * wrap this and provides the required context. + * + * @see https://trpc.io/docs/server/context + */ +export const createTRPCContext = async (opts: { headers: Headers }) => { + return { + auth, + db, + s3, + ...opts, + }; +}; + +/** + * 2. INITIALIZATION + * + * This is where the tRPC API is initialized, connecting the context and transformer. We also parse + * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation + * errors on the backend. + */ +const t = initTRPC.context().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +/** + * Create a server-side caller. + * + * @see https://trpc.io/docs/server/server-side-calls + */ +const createCallerFactory = t.createCallerFactory; + +/** + * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) + * + * These are the pieces you use to build your tRPC API. You should import these a lot in the + * "/src/server/api/routers" directory. + */ + +/** + * This is how you create new routers and sub-routers in your tRPC API. + * + * @see https://trpc.io/docs/router + */ +const createTRPCRouter = t.router; + +/** + * Middleware for timing procedure execution and adding an artificial delay in development. + * + * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating + * network latency that would occur in production but not in local development. + */ +const timingMiddleware = t.middleware(async ({ next, path }) => { + const start = Date.now(); + + if (t._config.isDev) { + // artificial delay in dev + 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; +}); + +/** + * Public (unauthenticated) procedure + * + * This is the base piece you use to build new queries and mutations on your tRPC API. It does not + * guarantee that a user querying is authorized, but you can still access user session data if they + * are logged in. + */ +const publicProcedure = t.procedure.use(timingMiddleware); + +export { createTRPCRouter, createCallerFactory, publicProcedure };