This content originally appeared on DEV Community and was authored by Tobias Lundin
Original article published at tolu.se/blog/sveltekit-rpc-hono/
Working example codebase for article can be found here github.com/tolu/sveltekit-hono-rpc
I've recently done some more work with SvelteKit, the first time since after runes (and more) were introduced to Svelte. Every time I return to Svelte (from React) I get blown away about how fun Svelte components are to work with; because of the component model, scoped styling and the built in transitions and animations. The library supports my goals better, letting me easily work directly with DOM APIs instead of fighting the runtime to avoid unnecessary re-renders with refs and caching.
What bothers me most about modern frameworks is their reliance on folder-structure routing and magic file names for data loading and compiler instructions (hello "use server").
While these might be great in a lot of cases I really miss the option for code-based routing.
This is especially true when it comes to API-routes, and here SvelteKit is no different.
<rant>
I've never liked the term meta-framework.
To me "meta" means that it goes beyond or transcends somehow and is not just a fullstack solution for a single library.
In that sense I feel thatNextJS,Nuxt,SolidStartandSvelteKitare all plain frameworks and the only meta-frameworks out there areAstroandTanstack Start, since they allow you to use different UI-libraries.
</rant>
Type-safe client data loading
Type-safety is important to me and has been since I fell in love with TypeScript before 1.0. When it comes to type-safety across network and serialization boundaries in full-stack frameworks it's even more important since validation is essential to trust the data being transmitted. For regular function-to-function calls in the client sphere this is not an issue. Server endpoints however can be reverse-engineered and called by anyone, not just your own client code.
For loading data from the client SvelteKit offers 2 solutions
- API-routes
- Remote Function (experimental)
API-routes
| Component | Example |
|---|---|
| File: | /src/routes/api/search/[term]/+server.ts |
| Endpoint: | /api/search/:term |
API-routes provide regular endpoints through folder hierarchy, giving you full control over request-response objects and middleware. You can use any HTTP client library—mine is ky.
The +server.ts file registers handlers by exporting HTTP-verb-named functions like GET and POST.
Example
/** File: /src/routes/api/search/[term]/+server.ts */
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ request, url }) => {
const term = url.searchParams.get('term');
const data = await getSearchResults(term);
return json(data, { headers: { 'Cache-Control': 'public' } });
};
/** File: page.svelte */
<script lang="ts">
let results = $state();
let term = $state('foo');
onMount(() => {
// should ofc do this when some input changes...
fetch(`/api/search/${term}`).then(res => res.json()).then(data => {
results = data;
});
})
</script>
Downsides
Request validation, sharing types with client and endpoint paths are your own problem to solve.
While folder hierarchy helps with organization, it doesn't tell the full story. Requests involve more than just paths and dynamic variables—they include headers, query parameters, and other metadata.
Folder structure sometimes scatters related concepts across multiple files that should be grouped together.
Remote Functions
| Component | Example |
|---|---|
| File: | /src/routes/api/search/data.remote.ts |
| Endpoint: | <auto-generated> |
Remote functions always run on the server but can be called from client code, using build time glue-generation and magic file-names (<name>.remote.ts). The awaited response is not a Response object but rather the data itself.
Declaring input parameters for remote functions require Standard Schema for validation purposes, which encourages developers to validate their data.
The functions can then be imported by client code and called as regular functions. Easy peasy.
Example
/** file: src/routes/search/data.remote.ts */
import { query } from '$app/server';
import { fetchSearchResults } from '$lib/api/search';
import * as v from 'valibot';
export const getSearchResults = query(v.string(), async (term) => {
return await fetchSearchResults(term);
});
/** file: src/routes/search/+page.svelte */
<script lang="ts">
import { getSearchResults } from './data.remote';
let results = $state();
let term = $state('foo');
onMount(() => {
// should ofc be called when some input value change in real life
getSearchResults(term).then(data => {
results = data;
})
})
</script>
Downsides
No control over the response headers, so impossible (or hard) to set Cache-Control or add Server-Timing.
All dynamic behavior in the function must be encoded as validated input parameters instead of request-properties.
Take away
Neither approach gives me the full control I want over routing and request-response handling while maintaining type-safe communication. They are very nice tools to have but I'd like more control, be closer to the HTTP and maintain control over types and validation, all in one package.
Also, can I have an OpenAPI spec and Swagger UI on the side? I'm not sure how I would solve that with these primitives.
Enter 🔥 Hono RPC (docs)
First of all Hono is a fantastic web application framework built on web standards and supports any runtime. It has many built-in middlewares for caching, authentication, CORS, JWT, logging etc
Here's how we'll combine the best of both approaches and add more functionality:
- replace API-routes entirely with Hono for full control of routing and gives us free reigns over code structure and folder hierarchy
- use
@hono/openapiandvalibotfor endpoint schema- and response validation and type safety - replace remote functions on the client by leveraging the Hono Client for endpoint discovery
- generate a Swagger UI from the OpenAPI specification
1. Replacing API-routes with Hono
We can pass all traffic on /api/* to Hono by leveraging rest-parameters in folder names and creating an API-route file like so:
/** File: /src/routes/api/[...rest]/+server.ts */
import { honoApiApp } from '$lib/api/hono-api';
import type { RequestHandler } from './$types';
// This handler will respond to all HTTP verbs (GET, POST, PUT, PATCH, DELETE, etc.)
// Pass all requests along to the honoApiApp, that we'll create next
export const fallback: RequestHandler = ({ request }) => honoApiApp.fetch(request);
Now we need to configure and mount our Hono app on the correct path, and handle the search result query.
For fun we'll add a global middleware for logging and setting the x-served-by response header.
/** File: /src/lib/api/hono-api.ts */
import { Hono } from 'hono';
const app = new Hono()
// Here we moved the search term from a query parameter to a path parameter
.get(
'/search/:term',
(c) => {
const term = c.req.param('term');
const results = await internalApi.getSearchResults();
return c.json(results, { headers: { 'Cache-Control': 'public, max-age=3600' } });
}
);
const honoApiApp = new Hono()
// Add global middleware for logging
.use(async (c, next) => {
console.log(`(🔥) - [${c.req.method}] ${new URL(c.req.url).pathname}`);
await next();
c.res.headers.set('x-served-by', 'hono');
})
// Mount the app on the /api-route
.route('/api', app);
export { honoApiApp };
2. Adding validation and type-safety
While you can add validators to Hono routes without OpenAPI, I chose this approach for several benefits: free Swagger UI, visual API representation, and advanced client response types that handle all defined HTTP status codes.
So let's extend our endpoint from before with validation and OpenAPI specification.
+ import { describeRoute, resolver, validator } from 'hono-openapi';
+ import * as v from 'valibot';
const app = new Hono()
.get(
'/search/:term',
+ describeRoute({
+ description: 'Provides search results for a given term',
+ responses: {
+ 200: {
+ description: 'Search results',
+ content: { 'application/json': {
+ schema: resolver(v.any()) // add actual schema for response here
+ }},
+ },
+ },
+ }),
+ validator(
+ 'param',
+ v.object({
+ term: v.string(),
+ }),
+ ),
(c) => {
const term = c.req.param('term');
const results = await internalApi.getSearchResults();
return c.json(results, { headers: { 'Cache-Control': 'public, max-age=3600' } });
}
);
/** honoApiApp is unchanged... */
+ export type HonoApiType = typeof honoApiApp;
This gives us endpoint validation and as a benefit a type we can use to create an api client for the browser. We'll get back to the OpenAPI when adding Swagger UI later.
3. Replace remote function with Hono client
First we need to create a new file for our API-client, instantiate it using our new HonoApiType and export it for use.
/** File: /src/lib/rpc-client.ts */
import { hc } from 'hono/client';
import type { HonoApiType } from '$lib/api/hono-api';
import { browser } from '$app/environment';
// Since this file might be imported by server code, on the SSR pass of a page,
// let's ensure we're in the browser before accessing "location"
export const apiClient = hc<HonoApiType>( browser ? location.origin : '' );
This typed Hono client gives us an object with full IntelliSense and type support for the endpoints paths and response types.
We can now replace our remote function in our svelte component with the apiClient like so:
/** file: src/routes/search/+page.svelte */
<script lang="ts">
- import { getSearchResults } from './data.remote';
+ import { apiClient } from '$lib/rpc-client';
let results = $state();
let term = $state('foo');
onMount(() => {
// should ofc be called when some input value change in real life
- apiClient(term).then(data => {
- results = data;
- })
+ apiClient.api.search[':term']
+ .$get({ param: { term: term } }).then(res => res.json())
+ .then(data => {
+ results = data;
+ });
})
</script>
It's not all I want in an API-client, but at least we now have intellisense and type safety all the way down. 🙌
And we have full control over API routes, middleware and response headers.
I would love to extend this client with niceties from
kyso that it has automatic retries and syntax-sugar like.json().
But that's for another time.
4. Swagger UI from the OpenAPI specification
Now that our endpoint has OpenAPI configuration, we can expose the schema and add Swagger UI for interactive API documentation.
We'll expose the schema and swagger directly on the honoApiApp like this:
/** File: /src/lib/api/hono-api.ts */
import { openAPIRouteHandler } from 'hono-openapi';
import { swaggerUI } from '@hono/swagger-ui';
// this is right below the code we've already written
const openApiPath = '/api/openapi';
honoApiApp.get(
openApiPath,
openAPIRouteHandler(honoApiApp, {
documentation: {
info: {
title: 'My Very Own API',
version: '1.0.0',
description: 'API documentation for the My Own API, by way of Hono 🔥',
},
},
})
);
honoApiApp.get('/api/docs', swaggerUI({ url: openApiPath }));
And that is it!
By adding validation and minimal documentation, we gained response schema types, a type-safe apiClient, and visual API documentation — all working together seamlessly.
The best part of this in many ways is that we achieved an API and RPC client that is totally independent of SvelteKit and so we're more free to rewrite the frontend without having to re-wire the entire API 🙌
Svelte ❤️🔥 Hono
✌️
This content originally appeared on DEV Community and was authored by Tobias Lundin
Tobias Lundin | Sciencx (2025-10-05T20:36:22+00:00) SvelteKit RPC with Hono. Retrieved from https://www.scien.cx/2025/10/05/sveltekit-rpc-with-hono/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.