DEV Community

Cover image for Typed TanStack Svelte Query options for tRPC in SvelteKit
Ionut-Cristian Florescu
Ionut-Cristian Florescu

Posted on

Typed TanStack Svelte Query options for tRPC in SvelteKit

I maintain tRPC-SvelteKit, a small library for building end-to-end typesafe APIs in SvelteKit apps with tRPC. I just released version 4.1, and its main feature is first-class support for TanStack Svelte Query.

In this post I'll show what it does and how to set it up, including server-side rendering.

The problem

tRPC gives you typed procedures. TanStack Query gives you caching, background refetching, mutations and invalidation. To use them together, every query needs a query key and a query function, and every mutation needs a mutation function. Writing those by hand for each procedure is repetitive, and it's easy to get a key wrong.

There are community adapters for this, but the ones I know of target older versions. trpc-svelte-query-adapter supports tRPC v10 and Svelte Query v5. trpc-svelte-query supports tRPC v11, but with SvelteKit 2 and Svelte Query v5. I wanted something for tRPC v11, SvelteKit 3 and Svelte Query v6, so I added it to tRPC-SvelteKit itself.

What's new: createTRPCQueryOptions

createTRPCQueryOptions(client) takes a tRPC client and gives you back an object that mirrors your router. For each procedure you get:

  • Queries: queryOptions(input, options), queryKey(input) and pathKey()
  • Mutations: mutationOptions(options), mutationKey() and pathKey()
  • Routers: pathKey(), which matches every query and mutation under that router

A few things I cared about while building it:

  • No dependency on any TanStack Query package. It only builds plain option objects, which you pass to createQuery and createMutation.
  • Types come from the tRPC client, so inputs and outputs match exactly, transformers included.
  • The query keys use the same format as tRPC's own TanStack Query integration, e.g. [['authors', 'list'], { input: 'smith', type: 'query' }].
  • It works with Svelte Query v6 and its runes API: createQuery(() => options).

Here's the short version:

const api = createTRPCQueryOptions(trpc(page));

const authors = createQuery(() => api.authors.list.queryOptions('smith'));

const save = createMutation(() =>
  api.authors.save.mutationOptions({
    onSuccess: () => queryClient.invalidateQueries({ queryKey: api.authors.pathKey() })
  })
);
Enter fullscreen mode Exit fullscreen mode

Setup

Install the packages (tRPC-SvelteKit 4 requires @trpc/server and @trpc/client v11):

npm i trpc-sveltekit@4.1 @tanstack/svelte-query
Enter fullscreen mode Exit fullscreen mode

I'm assuming you already have a trpc() client helper, as in the Getting started guide:

// src/lib/trpc/client.ts
import type { Router } from '$lib/trpc/router';
import { createTRPCClient, type TRPCClientInit } from 'trpc-sveltekit';

type TRPCClient = ReturnType<typeof createTRPCClient<Router>>;

let browserClient: TRPCClient;

export function trpc(init?: TRPCClientInit): TRPCClient {
  const isBrowser = typeof window !== 'undefined';
  if (isBrowser && browserClient) return browserClient;
  const client = createTRPCClient<Router>({ init });
  if (isBrowser) browserClient = client;
  return client;
}
Enter fullscreen mode Exit fullscreen mode

Next to it, define a helper that builds the query options:

// src/lib/trpc/query.ts
import { trpc } from '$lib/trpc/client';
import { createTRPCQueryOptions, type TRPCClientInit } from 'trpc-sveltekit';

export const api = (init?: TRPCClientInit) => createTRPCQueryOptions(trpc(init));
Enter fullscreen mode Exit fullscreen mode

Then create a QueryClient in a layout load function and provide it to your pages. Queries only run in the browser. The data needed for server-side rendering is prefetched in load functions, as shown in the next section:

// src/routes/+layout.ts
import { browser } from '$app/env';
import { QueryClient } from '@tanstack/svelte-query';
import type { LayoutLoad } from './$types';

export const load: LayoutLoad = () => ({
  queryClient: new QueryClient({ defaultOptions: { queries: { enabled: browser } } })
});
Enter fullscreen mode Exit fullscreen mode
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { QueryClientProvider } from '@tanstack/svelte-query';

  let { data, children } = $props();
</script>

<QueryClientProvider client={data.queryClient}>
  {@render children()}
</QueryClientProvider>
Enter fullscreen mode Exit fullscreen mode

Queries, rendered on the server

Prefetch the query in a +page.ts load function, so the page is rendered with data on the server:

// src/routes/+page.ts
import { api } from '$lib/trpc/query';
import type { PageLoad } from './$types';

export const load: PageLoad = async (event) => {
  const { queryClient } = await event.parent();
  await queryClient.prefetchQuery(api(event).greeting.queryOptions());
};
Enter fullscreen mode Exit fullscreen mode

Then use the same options in the page. The browser reuses the server response while hydrating instead of fetching again. queryOptions(input, options) accepts any createQuery option, such as staleTime, and adds the queryKey and queryFn:

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import { page } from '$app/state';
  import { api } from '$lib/trpc/query';
  import { createQuery } from '@tanstack/svelte-query';

  const greeting = createQuery(() =>
    api(page).greeting.queryOptions(undefined, { staleTime: 10_000 })
  );
</script>

<p>{greeting.data}</p>
Enter fullscreen mode Exit fullscreen mode

Mutations and invalidation

mutationOptions(options) does the same for createMutation. To refetch queries after a mutation, invalidate them with a key:

  • queryKey(input) matches a single query
  • pathKey() matches every query of a procedure, for any input, or of a whole router
<!-- src/routes/authors/+page.svelte -->
<script lang="ts">
  import { page } from '$app/state';
  import { api } from '$lib/trpc/query';
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';

  const queryClient = useQueryClient();

  let search = $state('');

  // the input is part of the query key, so changing `search` loads (and caches) new results
  const authors = createQuery(() => api(page).authors.list.queryOptions(search));

  const save = createMutation(() =>
    api(page).authors.save.mutationOptions({
      // refetch every `authors` query (for any input) once the mutation succeeds
      onSuccess: () => queryClient.invalidateQueries({ queryKey: api(page).authors.pathKey() })
    })
  );
</script>

<input type="search" bind:value={search} />
<ul>
  {#each authors.data ?? [] as author (author.id)}
    <li>{author.firstName} {author.lastName}</li>
  {/each}
</ul>
<button
  aria-busy={save.isPending}
  onclick={() => save.mutate({ id: null, firstName: 'Jane', lastName: 'Doe', bio: null })}
>
  Add author
</button>
Enter fullscreen mode Exit fullscreen mode

One limitation: subscriptions aren't supported, since TanStack Query doesn't handle them. You can still use them directly through the tRPC client.

Also in 4.1

Svelte Query is the main story, but the release also includes:

  • fixes for the WebSocket support, which is still experimental: the production server now shuts down cleanly on SIGTERM/SIGINT, and connections made before a router is attached are refused, so clients retry
  • @sveltejs/adapter-bun verified to work, including streaming and subscriptions over server-sent events
  • llms.txt and llms-full.txt for AI assistants and coding agents
  • a changelog

There are no breaking changes. If you're on 4.0, upgrading is just a version bump.

Links

If you try it, I'd like to hear how it goes. Issues and feedback are welcome on GitHub.

Top comments (0)