SvelteKit RPC with Hono


A 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 returned to SvelteKit after runes landed in Svelte, and every time I come back from React-land, I remember why Svelte components are such a joy: the component model, scoped styling, and built-in transitions just work. The library gets out of my way and lets me work directly with DOM APIs instead of wrestling with refs and memoization.

But here’s what bothers me about modern frameworks: the default reliance on custom conventions like folder-structure routing (file-based routing), magic file names (or exports) for data loading and compiler directives like Next’s "use client".

While these conventions may be intuitive for some and work well in many cases, I miss having the option for a code-based alternative; specifically for API-routes.

Type-safe client data loading

Type-safety matters to me - it has since I fell for 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 can however be reverse-engineered and called by anyone, not just your own trusted client code.

For loading data from the client, SvelteKit offers 2 solutions: API-routes and Remote Functions (experimental).

API-routes

File: /src/routes/api/search/[term]/+server.ts
Endpoint:/api/search/:term

API-routes provide regular endpoints with paths that match the folder hierarchy, and gives you full control over the 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, or the fallback-export if you want to handle all methods.

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: /src/routes/+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>

The Tradeoffs

You can’t set duplicate (append) headers from RequestHandlers making something like Server-Timing a thing that needs to be handled in middleware (defined in hooks.server.ts).

Request validation, sharing types, and well known endpoint paths, with the client is your problem to solve.

While folder hierarchy can help with code 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.

File-based routing can sometimes scatters related concepts across multiple files that should’ve be grouped together (think CRUD-controller).

Remote Functions

File: /src/routes/api/search/<name>.remote.ts
Endpoint:<auto-generated>

Remote functions always run on the server but can be called from client code like a regular function. The build step wires up the code needed and auto-generated a route. File names must follow the pattern (<name>.remote.ts).

The awaited return value from the function is not a Response-object but rather the data itself, with types included.

Declaring input parameters for remote functions require Standard Schema for validation purposes, which encourages developers to validate their input. Very good!

The functions are then simply imported in components or client code and called like regular functions. Easy peasy.

Example

file: src/routes/search/data.remote.ts

import { query, getRequestEvent } from '$app/server';
import { fetchSearchResults } from '$lib/api/search';
import * as v from 'valibot';

export const getSearchResults = query(v.string(), async (term) => {
  // get a hold of the actual request, or set cookies
  const { request, cookies } = getRequestEvent();
  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>

On top of this simple GET-example, remote functions comes packed with features like single-flight mutations, the ability to batch multiple requests and so on.


The Tradeoffs

All dynamic behavior in the function must be encoded as validated input parameters instead of request-properties.

No control over endpoint paths as they are auto-generated. What impact this has for logging and observability over time when you refactor the code I can only imagine.

These are still experimental so little issues are bound to be solved before launch, but as of writing this I could not get remote functions to work on Deno Deploy

The Gap

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.

What I really want is an embedded way to write a BFF (Backend-For-Frontend) directly in my app, with all the flexibility that it brings: like an OpenAPI-spec, Swagger UI and independant testing.

I’m not sure how I would solve that with the primitives provided via RequestHandler and Remote Functions.

Enter 🔥 Hono RPC (docs)

Here’s how we’ll combine the best of both approaches and add more functionality:

  1. replace API-routes, and remote functions, entirely with Hono for full control over routing and free reigns to structure our code 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 }) => {
  return honoApiApp.fetch(request)
};

Now we need to configure and mount our Hono app on the correct path (/api), and handle the search result query.

For fun we’ll add a global logging middleware 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.

You’ll also need to add these additional dependencies required by hono-openapi:
@hono/standard-validator and
@valibot/to-json-schema

file: /src/lib/api/hono-api.ts

+ 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 request input validation and as an added benefit a type we can use to create our api client for the browser. We’ll get back to the OpenAPI when adding Swagger UI later.

3. Replace remote functions 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.

Notice how we conditionally set the origin for the hc-client since this code might run in an SSR-pass where location is not available.

file: /src/lib/rpc-client.ts

import { hc } from 'hono/client';
import type { HonoApiType } from '$lib/api/hono-api';
import { browser } from '$app/environment';

export const apiClient = hc<HonoApiType>(
  browser ? location.origin : ''
);

This fully-typed fetch-client gives us an object with full IntelliSense for all the endpoints our Hono-app defines, with 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 doesn’t have all that I would want in an API-client, but at least we now have a standard fetch-client with full 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(s) have OpenAPI-specs, 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';

// other code remain the same

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 voilá, we have made the Swagger UI available at <origin>/api/docs.


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

✌️