tRPC-Nuxt

repository·main·Indexed 21 days ago

https://github.com/wobsoriano/trpc-nuxt

End-to-end typesafe API communication between a tRPC server and Nuxt applications. It allows the client to import type declarations without including server-side code in the client bundle. Features include support for authorization via context and middleware, optimistic UI updates using useNuxtData, HTTP response caching with responseMeta, and server-side procedure execution using createCaller().

Tokens
8.8K
Snippets
34
Records
38
Agent score
72%

What's inside trpc-nuxt

  1. Overview of tRPC-Nuxt

    main
    tRPC-Nuxt provides end-to-end typesafe APIs for Nuxt applications using tRPC.io. A key feature of this integration is that the client does not import any actual server-side code; it only imports the type declarations, ensuring type safety without bloating the client bundle with server logic.
  2. Understand the documentation project structure

    main

    The documentation site follows the Astro + Starlight directory structure:

    • src/content/docs/: Contains .md or .mdx files. Each file is exposed as a route based on its filename.
    • src/assets/: Place images here to embed them in Markdown using relative links.
    • public/: Place static assets like favicons here.
    • astro.config.mjs: Astro configuration file.
    • content.config.ts: Starlight content configuration.
    .
    ├── public/
    ├── src/
    │   ├── assets/
    │   ├── content/
    │   │   ├── docs/
    │   └── content.config.ts
    ├── astro.config.mjs
    ├── package.json
    └── tsconfig.json
  3. Use Nitro's Cache API for server-side tRPC calls

    main

    When performing server-side calls using a tRPC caller, you can leverage Nitro's defineCachedEventHandler to cache the results of those calls. This is useful for optimizing performance when calling your tRPC router from within Nuxt server routes.

    By wrapping your handler in defineCachedEventHandler, you can specify caching strategies like swr (stale-while-revalidate) and maxAge.

    import { appRouter } from '~/server/trpc/routers';
    
    const caller = appRouter.createCaller({});
    
    export default defineCachedEventHandler(
      async (event) => {
        const { name } = getQuery(event);
    
        const greeting = await caller.greeting({ name });
    
        return {
          greeting,
        };
      },
      {
        swr: true,
        maxAge: 10,
      },
    );
  4. Configure HTTP response caching with responseMeta

    main

    To enable edge caching (e.g., via Vercel's Edge Network), use the responseMeta option within createTRPCNuxtHandler. This function allows you to return custom headers, such as cache-control, which dictate how the server response should be cached by downstream proxies and CDNs.

    Ensure your response satisfies standard caching criteria as described in the tRPC.io documentation.

    // server/api/trpc/[trpc].ts
    import { createTRPCNuxtHandler } from 'trpc-nuxt/server';
    import { appRouter } from '~/server/trpc/routers';
    
    export default createTRPCNuxtHandler({
      router: appRouter,
      /**
       * @link https://trpc.io/docs/caching#api-response-caching
       */
      responseMeta(opts) {
        // cache request for 1 day + revalidate once every second
        const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
    
        return {
          headers: {
            'cache-control': `s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
          },
        };
      },
    });
  5. Authorize using middleware

    main

    For reusable authorization logic, use tRPC middleware. A middleware can intercept a request, check a condition in the context (e.g., ctx.user.isAdmin), and either throw a TRPCError with code 'UNAUTHORIZED' or call next() to proceed. You can then create a specialized procedure type (e.g., protectedProcedure) by calling .use(middleware) on a standard procedure, allowing you to easily protect multiple routes with the same logic.

    import { initTRPC, TRPCError } from '@trpc/server';
    
    // ... context function
    
    const t = initTRPC.context<Context>().create();
    
    const isAuthed = t.middleware(({ next, ctx }) => {
      if (!ctx.user?.isAdmin) {
        throw new TRPCError({ code: 'UNAUTHORIZED' });
      }
      return next({
        ctx: {
          user: ctx.user,
        },
      });
    });
    
    // you can reuse this for any procedure
    export const protectedProcedure = t.procedure.use(isAuthed);
    
    t.router({
      // this is accessible for everyone
      hello: t.procedure
        .input(z.string().nullish())
        .query(({ input, ctx }) => `hello ${input ?? ctx.user?.name ?? 'world'}`),
      admin: t.router({
        // this is accessible only to admins
        secret: protectedProcedure.query(({ ctx }) => {
          return {
            secret: 'sauce',
          };
        }),
      }),
    });
  6. Authorize using a resolver

    main

    You can perform authorization checks directly inside a tRPC resolver by inspecting the ctx object. If the user is not authorized, throw a TRPCError with the code 'UNAUTHORIZED'. This approach is useful for one-off checks or when authorization logic is highly specific to a single endpoint.

    import { initTRPC, TRPCError } from '@trpc/server';
    
    // ... context function
    
    const t = initTRPC.context<Context>().create();
    
    const appRouter = t.router({
      // open for anyone
      hello: t.procedure
        .input(z.string().nullish())
        .query(({ input, ctx }) => `hello ${input ?? ctx.user?.name ?? 'world'}`),
      // checked in resolver
      secret: t.procedure.query(({ ctx }) => {
        if (!ctx.user) {
          throw new TRPCError({ code: 'UNAUTHORIZED' });
        }
        return {
          secret: 'sauce',
        };
      }),
    });
  7. Create a type-safe tRPC client plugin

    main

    To use tRPC in your Vue components with full type-safety, create a Nuxt plugin. Use createTRPCNuxtClient and pass your AppRouter type. You must provide a link, such as httpBatchLink, pointing to your tRPC endpoint.

    // plugins/trpc.ts
    import { createTRPCNuxtClient, httpBatchLink } from 'trpc-nuxt/client';
    import type { AppRouter } from '~/server/trpc/routers';
    
    export default defineNuxtPlugin(() => {
      const trpc = createTRPCNuxtClient<AppRouter>({
        links: [httpBatchLink({ url: '/api/trpc' })],
      });
    
      return {
        provide: {
          trpc,
        },
      };
    });
  8. Perform optimistic updates with Mutation & Revalidation

    main

    You can use Nuxt's useNuxtData alongside trpc-nuxt to perform optimistic UI updates. This pattern involves:

    1. Generating a query key using getQueryKey from trpc-nuxt/client.
    2. Accessing the cached query data via useNuxtData using that key.
    3. Manually updating the local useNuxtData state before the mutation completes.
    4. Using refreshNuxtData(queryKey) to revalidate the data from the server upon a successful mutation.
    5. Rolling back the local state if the mutation fails.

    This ensures the UI feels instantaneous while maintaining data integrity through background revalidation.

    <script setup lang="ts">
    import type { inferRouterOutputs } from '@trpc/server';
    import type { AppRouter } from '~/server/trpc/routers';
    import { getQueryKey } from 'trpc-nuxt/client';
    
    const { $trpc } = useNuxtApp();
    const previousTodos = ref([]);
    
    // 1. Generate the key for the specific query
    const queryKey = getQueryKey($trpc.getTodos, undefined);
    
    type RouterOutput = inferRouterOutputs<AppRouter>;
    
    // 2. Access the cached value of useQuery
    const { data: todos } = useNuxtData<RouterOutput['getTodos']>(queryKey);
    
    async function addTodo(payload) {
      // Store previous state for rollback
      previousTodos.value = [...todos.value];
    
      // 3. Optimistically update the UI
      todos.value.push(payload);
    
      try {
        await $trpc.addTodo.mutate(payload);
        // 4. Invalidate/Refresh in the background on success
        await refreshNuxtData(queryKey);
      } catch {
        // 5. Rollback on failure
        todos.value = previousTodos.value;
      }
    }
    </script>
  9. Run commands for the tRPC-Nuxt documentation site

    main

    The documentation site is built using Astro and Starlight. Use pnpm to manage the development lifecycle and build process from the project root.

    pnpm install    # Installs dependencies
    pnpm dev        # Starts local dev server at localhost:4321
    pnpm build      # Build production site to ./dist/
    pnpm preview    # Preview build locally
    pnpm astro ...  # Run Astro CLI commands (e.g., astro add, astro check)
  10. Create context from request headers

    main

    Since createContext is called for every incoming request, you can use the H3Event object to extract information (like user identity) from request headers. This information is then passed to all tRPC resolvers via the ctx object. For example, you can extract a JWT from the authorization header to populate a user object in your context.

    import type { H3Event } from 'h3';
    import { decodeAndVerifyJwtToken } from './somewhere/in/your/app/utils';
    
    export async function createTRPCContext(event: H3Event) {
      // Create your context based on the event object
      // Will be available as `ctx` in all your resolvers
    
      const authorization = getRequestHeader(event, 'authorization');
      async function getUserFromHeader() {
        if (authorization) {
          const user = await decodeAndVerifyJwtToken(authorization.split(' ')[1]);
          return user;
        }
        return null;
      }
      const user = await getUserFromHeader();
    
      return {
        user,
      };
    }
    
    export type Context = Awaited<ReturnType<typeof createTRPCContext>>;
  11. Execute tRPC procedures from the server using createCaller()

    main

    When you need to call tRPC procedures directly from the server (for example, within a Nuxt server route or Nitro handler) instead of via a client-side network request, use the createCaller() method on your appRouter.

    createCaller() returns an instance of RouterCaller which allows you to execute queries and mutations as if they were local function calls, bypassing the HTTP layer. This is useful for server-to-server communication or when wrapping tRPC logic inside standard Nuxt API handlers.

    // Inside a Nuxt server handler (e.g., server/api/example.ts)
    import { appRouter } from '~/server/trpc/routers';
    
    export default defineEventHandler(async (event) => {
      // 1. Initialize the caller from your appRouter
      const caller = appRouter.createCaller({});
    
      // 2. Call the procedure directly
      const result = await caller.yourProcedureName({ input: 'data' });
    
      return { result };
    });