Building on Part 1: Enhancing Our SvelteKit tRPC Project with Dynamic Coffee Data

11 Jul 2025

Enhance your SvelteKit tRPC app with dynamic coffee data, Zod validation, loading states & client-side UX. Build scalable web apps with ease.

In Part 1, we built a scalable, modular SvelteKit and tRPC project. If you haven’t read it yet, check it out here before diving in.

In this guide (Part 2), we’ll take things further by integrating dynamic coffee data from an API, implementing loading states, error handling, and improving UI components for a seamless experience.

We will create a web app that displays hot or iced coffee based on the user’s selection.

Demo: https://svelte-trpc.michaelbelete.com/

Overview

By the end of this tutorial, you’ll:

  • Fetch real-time coffee data from an API.
  • Implement loading skeletons for better UX.
  • Handle errors gracefully with a user-friendly UI.
  • Enhance navigation using query parameters.

Folder Structure

src/
├── server/
│   ├── validations/
│   │   ├── coffee.schema.ts
│   ├── services/
│   │   ├── coffee.service.ts
│   ├── routers/
│   │   ├── coffee.router.ts
│   ├── appRouter.ts
│   ├── trpcContext.ts
├── lib/
│   ├── coffee.ts
│   ├── components/
│   │   ├── Loading.svelte
│   │   ├── Error.svelte
│   │   ├── CoffeeCard.svelte
├── routes/
│   ├── api/trpc/[...procedure].ts
│   ├── +layout.ts
│   ├── +page.ts
│   ├── +page.svelte

Backend Implementation

1. Using the Coffee API

Now, we will see how to use the architecture we created in Part 1. We are going to use the coffee API from Sample APIs. This API provides two endpoints:

To get started, we first need to examine the data returned by the API. The response follows this structure

[
  {
    "title": "Black Coffee",
    "description": "Svart kaffe är så enkelt som det kan bli med malda kaffebönor dränkta i hett vatten, serverat varmt. Och om du vill låta fancy kan du kalla svart kaffe med sitt rätta namn: café noir.",
    "ingredients": ["Coffee"],
    "image": "https://images.unsplash.com/photo-1494314671902-399b18174975?auto=format&fit=crop&q=80&w=1887&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
    "id": 1
  },
  ...
]

We’ll use Zod to ensure the data matches our expected structure. We’ll then expose a route allowing the user to switch between hot and iced coffee.

2. Defining our schema

a. coffee.ts from lib/coffee.ts

First, let’s define a type and constants we will use for our front and back end. Additionally, This file helps maintain consistent naming and query parameters across our codebase.

// src/lib/coffee.ts

export const COFFEE_LIST_DEP = 'coffee:list';
export const COFFEE_SEARCH_PARAM = 'coffeeType';
export const coffeeType = ['hot', 'iced'] as const;

export type CoffeeType = (typeof coffeeType)[number];
  • COFFEE_LIST_DEP: A string identifier used by SvelteKit’s invalidation or reactivity system.
  • COFFEE_SEARCH_PARAM: The query parameter key we will use to select hot or iced (e.g., ?coffeeType=hot).
  • coffeeType: All allowed values for coffee type(hot or iced).
  • CoffeeType: A TypeScript type defined based on coffeeType.

b. Defining Coffee Validations(Zod Schema)

Next, we define the Zod schema src/server/validations/coffee.schema.ts based on the returned JSON data from the API. This ensures each coffee item from the API meets our expectations:

// src/server/validations/coffee.schema.ts

import { z } from 'zod';
import { coffeeType } from '$lib/coffee';

export const coffeeSchema = z.object({
	title: z.string(),
	description: z.string(),
	ingredients: z.array(z.string()),
	image: z.string().url(),
	id: z.number()
});

export const getCoffeeListParamSchema = z.object({
	type: z.enum(coffeeType).default(coffeeType['0'])
});

export const coffeeListSchema = z.array(coffeeSchema);

export type Coffee = z.infer<typeof coffeeSchema>;
export type CoffeeList = z.infer<typeof coffeeListSchema>;
export type GetCoffeeListParam = z.infer<typeof getCoffeeListParamSchema>;
  1. coffeeSchema: Ensures each coffee object matches the required shape (title, description, etc.).
  2. getCoffeeListParamSchema: Validation we will use for our tRPC router input more about it when we are creating the coffee service.
  3. coffeeListSchema: Validates an array of coffee objects.
  4. Types (Coffee, CoffeeList, GetCoffeeListParam): Provide strong typing throughout your app.

3. Creating a coffee service

The Service layer in src/server/services/coffee.service.ts handles fetching from the external API, applying our Zod validations, and returning data (or throwing an error):

// src/server/services/coffee.service.ts

import {
	coffeeListSchema,
	type CoffeeList,
	type GetCoffeeListParam
} from '$server/validations/coffee.schema';
import { TRPCError } from '@trpc/server';

export class CoffeeService {
	private apiURL = 'https://api.sampleapis.com/coffee';

	async getCoffeeList(param: GetCoffeeListParam): Promise<CoffeeList> {
		const { type } = param;

		const response = await fetch(`${this.apiURL}/${type}`);

		if (!response.ok) {
			console.error('Failed to fetch coffee list', response);
			throw new TRPCError({
				code: 'INTERNAL_SERVER_ERROR',
				message: 'Failed to fetch coffee list'
			});
		}

		const data = await response.json();

		const validatedData = coffeeListSchema.safeParse(data);

		if (!validatedData.success) {
			console.error('Invalid coffee list data', validatedData.error);
			throw new TRPCError({
				code: 'INTERNAL_SERVER_ERROR',
				message: 'Invalid coffee list data'
			});
		}

		return validatedData.data;
	}
}

Key Points:

  • We throw a TRPCError if the response is not OK or if the Zod validation fails.
  • The validated data is returned for use by the tRPC router (and ultimately the front end).

4. tRPC

The tRPC setup exposes endpoints to our front end. We’ll define a router for coffee-specific procedures, and then register it in the main appRouter.ts.

Coffee Router

// src/server/routers/coffee.router.ts

import { CoffeeService } from '../services/coffee.service';
import { createTRPCRouter, publicProcedure } from '../trpcContext';
import { getCoffeeListParamSchema } from '../validations/coffee.schema';

export const coffeeRouter = createTRPCRouter({
  // 'list' procedure: fetch coffee list using the validated param schema
  list: publicProcedure
    .input(getCoffeeListParamSchema)
    .query(async ({ input }) => {
      return await new CoffeeService().getCoffeeList(input);
    })
});
  • list: A publicProcedure that accepts getCoffeeListParamSchema input. It calls the CoffeeService to fetch hot or iced coffee.

appRouter.ts

// src/server/appRouter.ts

import { createTRPCRouter } from './trpcContext';
import { coffeeRouter } from './routers/coffee.router';

export const appRouter = createTRPCRouter({
  // Register the coffee router under 'coffee'
  coffee: coffeeRouter
});

// Provide a type for the entire app router
export type AppRouter = typeof appRouter;

Here, we combine all our project’s routers into one root router (appRouter), making them available under paths like /api/trpc/coffee.list.

Frontend Implementation

Folder Structure (Recap)

Our frontend code lives primarily in the src/routes/ and src/lib/ directories:

src/
├── lib/
│   ├── coffee.ts
│   ├── components/
│   │   ├── Loading.svelte
│   │   ├── Error.svelte
│   │   ├── CoffeeCard.svelte
├── routes/
│   ├── api/trpc/[...procedure].ts
│   ├── +layout.ts
│   ├── +page.ts
│   ├── +page.svelte

1. Create the Needed Components

We’ll use three reusable components in src/lib/components/ to handle loading states, errors, and coffee card displays.

1.1. Loading.svelte

Displays a skeleton to indicate data is being fetched.

<!-- src/lib/components/Loading.svelte -->
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
	{#each Array(6) as _}
		<div class="animate-pulse rounded-lg bg-white p-4 shadow-md">
			<div class="mb-4 h-48 rounded-lg bg-gray-200"></div>
			<div class="mb-2 h-4 w-3/4 rounded bg-gray-200"></div>
			<div class="mb-4 h-4 w-1/2 rounded bg-gray-200"></div>
			<div class="space-y-2">
				<div class="h-3 w-full rounded bg-gray-200"></div>
				<div class="h-3 w-5/6 rounded bg-gray-200"></div>
			</div>
		</div>
	{/each}
</div>

1.2. Error.svelte

Shows an alert or error message if the data fetch fails.

<!-- src/lib/components/Error.svelte -->
<script lang="ts">
	let { error } = $props();
</script>

<div class="rounded border-l-4 border-red-400 bg-red-50 p-4">
	<div class="flex items-center">
		<svg class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
			<path
				stroke-linecap="round"
				stroke-linejoin="round"
				stroke-width="2"
				d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
			/>
		</svg>
		<p class="ml-3 text-red-700">{error.message}</p>
	</div>
</div>

1.3. CoffeeCard.svelte

Displays individual coffee data, including an image, title, description, and ingredients.

<!-- src/lib/components/CoffeeCard.svelte -->
<script lang="ts">
	import type { Coffee } from '$server/validations/coffee.schema';

	type CoffeeCardProps = {
		coffee: Coffee;
	};

	let { coffee }: CoffeeCardProps = $props();
</script>

<div class="overflow-hidden rounded-lg bg-white shadow-md transition-transform hover:scale-105">
	<img src={coffee.image} alt={coffee.title} class="h-48 w-full object-cover" />
	<div class="p-4">
		<h3 class="mb-2 text-xl font-semibold text-gray-800">{coffee.title}</h3>
		<p class="mb-4 text-gray-600">{coffee.description}</p>
		<div class="mt-auto space-y-4">
			<h4 class="font-medium text-gray-700">Ingredients:</h4>
			<div class="flex flex-wrap gap-2">
				{#each coffee.ingredients as ingredient}
					<span
						class="coffees-center inline-flex rounded-full bg-amber-100 px-3 py-1 text-sm font-medium text-amber-800"
					>
						{ingredient}
					</span>
				{/each}
			</div>
		</div>
	</div>
</div>

2. Disable Server-Side Rendering for Smooth Skeleton Loading

By default, SvelteKit uses Server-Side Rendering (SSR), which can complicate skeleton loading scenarios since the server might prefetch and serve rendered HTML. To maintain a purely client-side approach—where the user sees the skeleton loader before data arrives—we can disable SSR in the layout.

In src/routes/+layout.ts, set:

// src/routes/+layout.ts

export const ssr = false;

Why Disable SSR?

  • Ensures that the user’s first view is a client-rendered skeleton, rather than a server-rendered snapshot of either partial or full data.
  • Simplifies managing loading states and transitions in code, since everything happens on the client.

3. Create the +page.ts Load Function

SvelteKit’s load functions can run on either the server or client side. Since we want a smooth, reactive UI, we’ll do this client-side, referencing our tRPC client.

<!-- src/routes/+page.ts -->
import { COFFEE_LIST_DEP, COFFEE_SEARCH_PARAM, type CoffeeType } from '$lib/coffee';
import { trpc } from '$lib/trpc';

export async function load({ depends, url }) {
	depends(COFFEE_LIST_DEP);

	const coffeeType = url.searchParams.get(COFFEE_SEARCH_PARAM);

	let type: CoffeeType = 'hot';

	if (coffeeType === 'iced') {
		type = 'iced';
	}

	async function getCoffee() {
		return await trpc.coffee.list.query({
			type: type
		});
	}

	return {
		getCoffee: getCoffee(),
		coffeeType: type
	};
}

Key Points:

  • depends(COFFEE_LIST_DEP): Tells SvelteKit to refetch this data if we invalidate the key COFFEE_LIST_DEP.
  • coffeeParam: Reads the coffeeType parameter from the URL.
  • getCoffee(): Uses the trpc client to call coffee.list.query.
  • Returns: The function returns an object with the data needed in +page.svelte.

4. Create the +page.svelte

Finally, we wire everything together in SvelteKit’s page component. This file loads data from +page.ts, displays skeletons while fetching, and allows the user to toggle between hot and iced coffee.

<!-- src/routes/+page.svelte -->
<script lang="ts">
	import { goto, invalidate } from '$app/navigation';
	import { COFFEE_LIST_DEP, COFFEE_SEARCH_PARAM, coffeeType } from '$lib/coffee.js';
	import CoffeeCard from '$lib/components/CoffeeCard.svelte';
	import Error from '$lib/components/Error.svelte';
	import Loading from '$lib/components/Loading.svelte';

	let { data } = $props();

	async function loadCoffeeList(coffee: string) {
		await goto(`?${COFFEE_SEARCH_PARAM}=${coffee}`, { keepFocus: true });
		invalidate(COFFEE_LIST_DEP);
	}
</script>

<div class="min-h-screen bg-gray-50">
	<div class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
		<div class="mb-8 text-center">
			<h1 class="mb-2 text-4xl font-bold text-gray-900">Coffee Explorer</h1>
			<p class="text-lg text-gray-600">Select your preferred coffee style below</p>
		</div>

		<div class="mb-12 flex justify-center space-x-2">
			{#each coffeeType as coffee}
				<button
					onclick={() => loadCoffeeList(coffee)}
					class="rounded-full px-6 py-2 font-medium transition-all"
					class:active-tab={data.coffeeType === coffee}
					class:inactive-tab={data.coffeeType !== coffee}
				>
					{coffee}
				</button>
			{/each}
		</div>

		{#await data.getCoffee}
			<Loading />
		{:then coffees}
			<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
				{#each coffees as coffee}
					<CoffeeCard {coffee} />
				{/each}
			</div>
		{:catch error}
			<Error {error} />
		{/await}
	</div>
</div>

<style lang="postcss">
	.active-tab {
		@apply bg-amber-800 text-white;
	}

	.inactive-tab {
		@apply bg-amber-100 text-amber-800 hover:bg-amber-200;
	}
</style>

Highlights:

  • Await Block: Displays <Loading /> while data is fetching, then <CoffeeCard> list on success, or <Error> on failure.
  • Buttons: Dynamically switch coffee type (hot or iced) by updating the URL query param. SvelteKit re-runs +page.ts when the query changes.
  • Styling: We use Tailwind classes for quick styling, plus active-tab vs. inactive-tab for visual feedback.

Conclusion

With our backend (services, routers, and Zod validation) in place and the frontend (components, page load functions, and SSR configuration) fully integrated, our SvelteKit + tRPC project is now primed to serve dynamically fetched coffee data—either hot or iced—based on user selection. We’ve:

  1. Defined robust schemas using Zod to validate incoming data from an external API.
  2. Created a clear service layer that fetches and processes coffee data.
  3. Exposed tRPC routers for type-safe communication between the backend and frontend.
  4. Implemented skeleton loading and error handling in the UI for a smooth user experience.
  5. Leveraged client-side rendering (SSR disabled) for better control over loading states and an interactive, responsive interface.

This architecture ensures your application remains scalable and maintainable, whether you plan to add more endpoints, new features, or enhanced UI components. Feel free to build on this foundation—introducing features such as search filterspagination, or authentication—all while keeping your project’s structure clean and organized. Happy coding!

If you’d like to explore the full source code, check out the repository on GitHub:

For more updates and future content, feel free to subscribe to my newsletter and follow me on Twitter:

Ready to Build Something Great?

I’m open to senior full-stack roles and select freelance projects. Let’s collaborate to bring your next idea to life — fast, scalable, and impactful.