DEV Community

Cover image for SvelteKit RPC with Hono
Tobias Lundin
Tobias Lundin

Posted on

SvelteKit RPC with Hono

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

What we're working with

SvelteKit in Brief

SvelteKit is the official full-stack framework for Svelte. It handles routing, server-side rendering, and API endpoints through a file-based convention. Think of it as Svelte's version of Next.js (React), Nuxt (Vue) or SolidStart (SolidJS).

Hono in Brief

Hono is a lightweight web framework built on Web Standards, meaning it runs anywhere: Node.js, Deno, Bun, Cloudflare Workers, you name it. It's fast, provides excellent TypeScript support, and comes with middleware for everything from CORS to JWT authentication. Think Express.js, but modern and runtime-agnostic.

What is RPC and Why Should You Care?

Remote Procedure Call (RPC) is a pattern where you call server-side functions from your client code as if they were local functions. No manual endpoint construction, no maintaining separate client/server type definitions—just function calls that happen to execute on the server.

A bit of history: RPC isn't new. We've had XML-RPC (1998), JSON-RPC, gRPC, and countless other implementations. What's changed is that modern JavaScript frameworks have rediscovered how pleasant this pattern can be when paired with TypeScript's type inference. Instead of maintaining OpenAPI specs and generating client code, your types just... work.

The problem RPC solves: In traditional REST APIs, you maintain parallel universes—server endpoints and client code that calls them—with no guaranteed synchronization. Change a route? Update the client. Modify response shape? Hope you caught all the call sites. RPC collapses this duplication: one function definition serves both client and server, with the type system ensuring they stay in sync.
Modern frameworks like tRPC, Remix actions, Next.js server functions, and SvelteKit's remote functions all embrace this pattern. The differences lie in how much control you retain over the HTTP layer underneath.


Why I'm Writing This

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.

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

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. replace API-routes entirely with Hono for full control of routing and gives us free reigns over code structure and folder hierarchy
  2. use @hono/openapi and valibot for endpoint schema- and response validation and type safety
  3. replace remote functions on the client by leveraging the Hono Client for endpoint discovery
  4. 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);
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 : '' );
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 ky so 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 }));

Enter fullscreen mode Exit fullscreen mode

That's it

By adding validation and minimal documentation, we gained response schema types, a type-safe apiClient, and visual API documentation — all working together seamlessly. In many ways we gained an embedded BFF (Backend-For-Frontend) that required minimal boilerplate code and that can leverege the full power and flexibility of Hono.

The best part? In using Hono for this API (and RPC client) we are completely independent of SvelteKit. You're (more) free to rewrite the frontend with another library without porting the entire API to another framework's conventions and standards 🙌

Svelte ❤️🔥 Hono

✌️

Top comments (0)