Using Hono with SvelteKit: Full type-safety with RPC
GO BACKPublished at: Jan 29, 2024
Introduction
I fell in love with Svelte and SvelteKit recently and it gave me the fuel I needed to start some personal projects. I was feeling hostage by React and maybe that was ok until I needed some features that only RSC/Next.js was going to give me. That was the Armageddon. After dwelling with some ClojureScript and other FP languages that compile to JS I stumbled on the Svelte community and it was a blast how easy and beautiful it is.
Why Hono?
If you ever used an SSRish framework you know that it has a way to create endpoints within its (probably) file-based routing. Of course, you can also simply inject the data into the route itself using its loaders.
But when trying to fix the problem of invalidation and wanting to get the same data in the same way on the client side you will probably lose some of the type-safety on the params, URL, response, etc.
After using tRPC on Next.js (t3) I’ve been passionate about the possibility of having end-to-end type-safety + a way to write your data sources not bound to the file-based route and its default APIs.
Hono offers good DX and a blazingly fast library to help you build your backend with native RPC embedded in its code. It has Zod support and it’s written with TypeScript with very readable types so you can understand what’s happening. It also supports Bun and a bunch of runtimes so you can run it everywhere. Check the documentation! It’s also very helpful.
Setup
First we need to scaffold a SvelteKit project using pnpm:
pnpm create svelte@latest sveltekit-hono-playground
Enter fullscreen mode Exit fullscreen mode
I used the following options (but it doesn’t matter):
Now let’s install hono (and validations dependencies):
pnpm install hono zod @hono/zod-validator
Enter fullscreen mode Exit fullscreen mode
Now let’s run the project and keep it running with:
pnpm dev
Enter fullscreen mode Exit fullscreen mode
You should see something resembling the following:
The Backend
Let’s focus now on creating some endpoints for us. Please don’t get caught up on file placement and structure. Do it however you please and contact me if you fail to place the logic inside your preferred structure and I will try to help you.
./src/lib/api.ts
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { z } from 'zod';
export const Task = z.object({
id: z.string().uuid(),
name: z.string().min(1),
done: z.boolean()
});
export type Task = z.infer<typeof Task>;
export const TaskCreateInput = Task.pick({ name: true });
export type TaskCreateInput = z.infer<typeof TaskCreateInput>;
export const TaskParam = Task.pick({ id: true });
export type TaskParam = z.infer<typeof TaskParam>;
/**
* This will be our in-memory data store
*/
let tasks: Task[] = [];
export const router = new Hono()
.get('/tasks', (c) => c.json<Task[]>(tasks))
.post('/tasks', zValidator('json', TaskCreateInput), (c) => {
const body = c.req.valid('json');
const task = {
id: crypto.randomUUID(),
name: body.name,
done: false
};
tasks = [...tasks, task];
return c.json(task);
})
.post('/tasks/:id/finish', zValidator('param', TaskParam), (c) => {
const { id } = c.req.valid('param');
const task = tasks.find((task) => task.id === id);
if (task) {
task.done = true;
return c.json(task);
}
throw c.json({ message: 'Task not found' }, 404);
})
.post('/tasks/:id/undo', zValidator('param', TaskParam), (c) => {
const { id } = c.req.valid('param');
const task = tasks.find((task) => task.id === id);
if (task) {
task.done = false;
return c.json(task);
}
throw c.json({ message: 'Task not found' }, 404);
})
.post('/tasks/:id/delete', zValidator('param', TaskParam), (c) => {
const { id } = c.req.valid('param');
tasks = tasks.filter((task) => task.id !== id);
return c.json({ message: 'Task deleted' });
});
export const api = new Hono().route('/api', router);
export type Router = typeof router;
Enter fullscreen mode Exit fullscreen mode
And to make it work:
./src/routes/api/[…paths]/+server.ts
import { api } from '$lib/api';
import type { RequestHandler } from '@sveltejs/kit';
export const GET: RequestHandler = ({ request }) => api.fetch(request);
export const POST: RequestHandler = ({ request }) => api.fetch(request);
Enter fullscreen mode Exit fullscreen mode
Above you can see the code that redirects the requests made with a URL starting with /api
to be handled by Hono. The fetch method can be used in various ways to lift a Hono server including Bun. You can test /api/tasks
on the browser you should see an empty JSON array.
RPC
So now let’s add the RPC part:
./src/lib/make-client.ts
Please be aware that this part is not as straightforward as the rest. Make sure you give it a good read. I will be explaining it step-by-step on what is happening and why is it happening that way.
import type { Router } from '$lib/api';
import { hc } from 'hono/client';
let browserClient: ReturnType<typeof hc<Router>>;
export const makeClient = (fetch: Window['fetch']) => {
const isBrowser = typeof window !== 'undefined';
const origin = isBrowser ? window.location.origin : '';
if (isBrowser && browserClient) {
return browserClient;
}
const client = hc<Router>(origin + '/api', { fetch });
if (isBrowser) {
browserClient = client;
}
return client;
};
Enter fullscreen mode Exit fullscreen mode
Now we will be using it like this to load the tasks into our view:
./src/routes/+page.ts
import { makeClient } from '$lib/make-client';
import type { PageLoad } from './$types';
export const load = (async ({ fetch }) => {
const client = makeClient(fetch);
const tasks = await client.tasks.$get();
if (!tasks.ok) {
return { tasks: [] };
}
return {
tasks: await tasks.json()
};
}) satisfies PageLoad;
Enter fullscreen mode Exit fullscreen mode
But what about that
makeClient
monster you made? Common Reader
The make client needs to be like that because it’s being used on the backend and on the browser to bring data to the page.
You can see it receives fetch as a parameter, right? That is because (see ./src/routes/+page.ts) SvelteKit makes a custom fetch available from the backend to us. Of course, on the client side, Hono will be using the default window.fetch
while on the backend, it’s just not that simple.
That’s because, in the backend context, we don’t have access to the origin URL, and imagine doing self-requests every time to grab data, that would be not good.
SvelteKit fixes that by giving you a fetch that, before going to the network layer, tries to resolve the request into its handlers, which will fall back into calling what we wrote on ./src/routes/api/[...paths]/+server.ts
and if Hono can identify the route, will simply run its handler and return the value, again, skipping the network layer.
You can read more about it if you want.
Also, we are sprinkling some singleton pattern esque to make sure that when creating the client on the browser we don’t recreate the client at every usage.
The window.location.origin
part is to not break the $url
methods on the browser so we can easily invalidate the request on the client side. You can also read more about this.
Building Our Application
Our Task Manager will have only one page. I have added a little progressive enhancement, just for demonstration, by adding use:enhance
on the form. And now you can see the whole app:
./src/routes/+page.server.ts
import { TaskCreateInput } from '$lib/api';
import { makeClient } from '$lib/make-client';
import type { Actions } from '@sveltejs/kit';
export const actions = {
async default({ fetch, request }) {
const client = makeClient(fetch);
const form = await request.formData();
const data = TaskCreateInput.parse(Object.fromEntries(form));
const response = await client.tasks.$post({
json: data
});
if (!response.ok) {
return {
message: 'An error occurred'
};
}
return await response.json();
}
} satisfies Actions;
Enter fullscreen mode Exit fullscreen mode
./src/routes/+page.svelte
<script lang="ts">import { enhance } from "$app/forms";
import { invalidate } from "$app/navigation";
import { makeClient } from "$lib/make-client.js";
export let data;
const client = makeClient(fetch);
let isLoading = false;
let taskName = "";
async function handleActionClick(id, action) {
try {
isLoading = true;
await client.tasks[":id"][action].$post({
param: { id }
});
await invalidate(client.tasks.$url());
} catch (error) {
console.error(error);
} finally {
isLoading = false;
}
}
</script>
<h1>BTMW: The best task manager in the world</h1>
<div>
<h2>New Task</h2>
<form method="POST" use:enhance>
<input type="text" name="name" required bind:value={taskName} disabled={isLoading} autofocus />
<button type="submit" disabled={isLoading}>Add</button>
</form>
</div>
<div>
<h2>My Tasks</h2>
{#if data.tasks.length === 0}
<p>You don't have any tasks! Be free little bird</p>
{:else}
<ul>
{#each data.tasks as task (task.id)}
<li>
{task.done ? '✅' : '⬛️'}
{task.name}
{#if !task.done}
<button type="button" on:click={() => handleActionClick(task.id, 'finish')}
>Finish</button
>
{:else}
<button type="button" on:click={() => handleActionClick(task.id, 'undo')}>Undo</button>
<button type="button" on:click={() => handleActionClick(task.id, 'delete')}
>Delete</button
>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
Enter fullscreen mode Exit fullscreen mode
Notice how Hono makes invalidating RPC requests frictionless by giving us the $url
method. For every action that can be done on a single task, it invalidates the task list so we can have the updated task list without refreshing the page.
This will give us the following responsive, fast, progressively enhanced, tasty SPA to use as our task manager:
with Eden?
Elysia and (Eden)[https://github.com/elysiajs/eden] are awesome! I’ve been trying it and it gives probably the same features as Hono. Maybe some even better DX sometimes. But the lack of Zod support (I don’t want to learn a new validation framework), $url
method, and running on Bun for now made me choose to focus on Hono.
Inspiration
This article, the Hono docs, Elysia SvelteKit example, trpc-sveltekit and lot’s of general reading.
Repository
You can find it here.
What comes next?
Next will be trying to create a post on how and why use @tanstack/query on SvelteKit. Keep in touch!
Thanks
Thank you for reading. Feedback welcome to help me share knowledge effectively.
About the author
Hello there! I’m bop, a dedicated software engineer driven by a love for continuous learning and creative exploration. Stay connected with me for updates on my current activities and exciting projects.
- 🌐 Website: bop.systems
- 📧 Email: brunooliveira37@hotmail.com
📱 Social Media:
You can also buy me a coffee to support me, if you like what I do