Back to blog

The case for full-stack libraries

Why software libraries are better when they span both the frontend and the backend, and how Fragno helps you build full-stack libraries.

Wilco Kruijer

The case for full-stack libraries

Software libraries are built to integrate on either the frontend or the backend, leaving the library user responsible for the glue code. Many domains span both sides. While libraries reduce the implementation effort somewhat, it could be better. Every project demands the re-implementation and custom wiring of the library: defining API routes, documentation (OpenAPI), client-side integration, and more.

Take an AI/LLM chatbot: on the frontend, user interaction is the heart of the experience, while the backend holds API keys and handles function calling. If we look at the openai library, it only provides the backend functionality. The user is responsible for defining routes and handling communication. Function calling requires code on both sides, and you'd probably also want to support streaming responses. All of this must be implemented by the user.

As I was working on an AI chatbot, I realized that I needed a full-stack library, and there really was no good solution for building one. The biggest hurdle I saw was the many frontend and backend frameworks that are out there. If a full-stack library is to be useful, it should be able to work with all of them. That's why I'm now building Fragno, a framework-agnostic toolkit for building full-stack libraries.

This is not entirely a new concept. There are already libraries that make good use of a full-stack approach. These fall into domains that naturally span both the frontend and the backend.

Sources of inspiration are:

  1. Better Auth. It shows that a full-stack approach to building a library can be very successful. Authentication is a domain that clearly has frontend and backend components. Better Auth integrates with the major full-stack frameworks and has a data layer as well. It integrates easily and is very ergonomic to work with.1

  2. Polar. As a payment service provider, Polar integrates with full-stack frameworks to provide a checkout page that embeds into the user's application. Their list of supported frameworks through their adapters is quite impressive.

Neither of these expose their primitives for creating full-stack libraries. This is precisely what Fragno aims to do: provide the primitives for creating full-stack libraries.

Goals

While building Fragno, I had a number of core goals in mind:

  1. Libraries need to be able to define API routes.
  2. Libraries need to be able to define a client-side interface.
  3. Integration for library users should be as easy as possible.

Sub-goals naturally follow from these goals, we'll discuss them in the next sections.

Defining server-side API routes

Goal 1: libraries need to be able to define API routes.

Naturally, this means that the routes defined in the library should be able to be mounted into the user's application. Of course, that's easier said than done. There are many ways to define API routes, depending on the programming language and framework you're working with. In the JavaScript world, we have full-stack frameworks like Next.js, React Router/Remix, Nuxt, SvelteKit, and SolidStart. On the server-focused side, there are frameworks such as Astro, Express, and Hono. I believe these should all be supported for Fragno to be useful.2

The focus here is to support a wide range of frameworks. We do this by taking the common denominator: HTTP. Modern frameworks have standardized on the Request and Response objects, so an abstraction is definitely possible.

These days, expectations are high. If I'm defining API routes, I'd at least want: type safety, request validation, error handling, support for various content types, support for streaming, etc. I took inspiration from web frameworks such as Hono and implemented a good chunk of these features.

Defining a route in Fragno looks something like this:3

routes/get-todos.ts
import { defineRoute } from "@fragno-dev/core";
import { z } from "zod";

export const getTodosRoute = defineRoute({
  method: "GET",
  path: "/todos",
  outputSchema: z.array(/* TodoSchema */),
  handler: async (_, { json }) => {
    return json([
      {
        id: "1",
        text: "Learn Fragno",
        done: false,
        createdAt: new Date().toISOString(),
      },
    ]);
  },
});

Frontend state management

Goal 2: libraries need to be able to define a client-side interface.

On the frontend side, there's also standardization around a few key abstractions. The most important one is reactivity: re-rendering the UI whenever data changes. Every major modern framework builds on this primitive, even if the implementations differ: React, Vue, Svelte, Solid, etc.

Data fetching is more diverse, but these days most developers prefer a TanStack Query-style approach. With it, you can declaratively define data-fetching logic with automatic caching, re-fetching, and invalidation based on a key. There are other approaches too, like RPC calls or GraphQL. But the simplest and most widely adopted option is the TanStack Query-style approach.

To offer these features, Fragno uses Nano Stores and Nano Stores Query to create fully reactive client-side data fetchers. This means that caching, re-fetching, and invalidation are all handled out of the box.

Fragno offers a client builder that allows library authors to define client-side interfaces based on the server-side routes:4

src/index.ts
import { getTodosRoute } from "./routes/get-todos";
import { addTodoRoute } from "./routes/post-todos";

import { createClientBuilder } from "@fragno-dev/core/client";
import type { FragnoPublicClientConfig } from "@fragno-dev/core";

export function createClient() {
  const cb = createClientBuilder(todosDefinition, {}, [getTodosRoute, addTodoRoute]);

  return {
    useTodos: cb.createHook("/todos"),
    useAddTodo: cb.createMutator("POST", "/todos"),
  };
}

Library authors are not limited to using the client builder; they can also define client-side interfaces manually. Nano Stores' primitives like computed, atom, and effect can be used to create custom stores.

Integration simplicity

Goal 3: Integration for library users should be as easy as possible.

Both Better Auth and Polar show how backend integration can be done. A simple shim over the (req: Request) => Response functionality is sufficient to integrate with most frameworks.

The following code snippets show how a user would integrate a Fragno library into their application. As you can see, a bunch of frameworks are already supported.

app/api/example-fragment/[...all]/route.ts
import { createExampleFragmentInstance } from "@/lib/example-fragment-server";

const exampleFragment = createExampleFragmentInstance();
export const { GET, POST, PUT, PATCH, DELETE } = exampleFragment.handlersFor("next-js");

Like the backend component, it only takes a few lines of code to integrate. The useFragno function is framework-specific and accommodates the reactivity of the framework.

lib/example-fragment-client.ts
import { createExampleFragmentClient } from "@fragno-dev/example-fragment/react";

export const exampleFragment = createExampleFragmentClient();

For users to start using a Fragno library, they simply import the object created in the snippet above. It then looks very similar to TanStack Query's useQuery hook:

components/ExampleComponent.tsx
import { exampleFragment } from "@/lib/example-fragment-client";

const { useTodos } = exampleFragment;

export function MyComponent() {
  const { data, loading, error } = useTodos();

  return (
    <div>
      <h1>Example Component</h1>
      {loading ? <div>Loading...</div> : <div>{data}</div>}
    </div>
  );
}

The full integration process can be found on the User Quick Start page.

Under the hood

I've already talked about integration and client-side state management. There are a few other things that had to happen to make Fragno work.

Type safety & Code splitting

Every Fragno library has a single canonical definition. This definition is used to create both the server and client objects. Hooks created with the client builder methods are fully type-safe, meaning that the data returned by the useTodos hook is guaranteed to be an array of Todo objects. Fragno schemas accept schemas that are compatible with Standard Schema.

A problem with this setup is that the single object contains code that should only be executed on the server (the route handlers). All full-stack frameworks solve this by code-splitting, and so does Fragno. @fragno-dev/unplugin-fragno is a plugin that automatically splits the code between client and server bundles. This build step is only required for library authors, not for end-users. I believe this is important to avoid increasing friction on the user's side.

Middleware

Middleware in Fragno works a bit differently than in web frameworks such as Express or Hono. In Fragno, middleware is not defined as part of the library route definition. Instead, it's a tool for users to write code that runs in front of route handlers defined by the library. This enables users to implement features such as authentication, rate-limiting, and more.

Config, dependencies, and services

Most libraries have some level of configuration. In Fragno, the library config is the basic contract between the library author and the user. It can be used to pass configuration to the library, such as API keys and other options. It can also be used to let users react to events happening in the library by providing callback functions. Think of something like onTodoCreated.

Dependencies can be used to create library-private objects that can be used in route handlers. Services are functions that are available to users in the server-side context. This lets library authors expose functionality to users that they can in turn use to do things like background processing. Both dependencies and services are server-side only and are split out from the client bundle by the plugin.

What's missing

I've mostly talked about defining routes in the context of a frontend calling a server-side route. Another important use case for routes in the context of libraries is handling webhooks. This is a common use case when integrating with third-party services. Of course, Fragno can already receive webhook requests, but as long as there's no way to store the received data in a database, it's not very useful.

To be truly full-stack, I believe that libraries also need to be able to integrate with the data layer. Better Auth does this very cleanly, but I believe there are pieces missing, such as support for transactions. This is something that should be supported in Fragno eventually.

Of course, there are other smaller (and bigger) things that are missing and could be added. Top of mind currently is server-side rendering support. Nanostores already has some support for pre-fetching data on the server, then hydrating it in the client. However, making this work in a framework-agnostic manner is relatively complicated.

Conclusion

Fragno is an attempt to bring the full-stack mindset to libraries: ship routes, a client, and a tiny, framework-agnostic integration surface. The first iteration is now available, completely free and open-source. Any feedback is more than welcome!

If you want to learn more about Fragno:

  • Read the Library Quick Start to learn how to build full-stack libraries.
  • Read the User Quick Start for a full walkthrough of how a user integrates a library into their application.

And if you want to stay updated on Fragno's progress: please star us on GitHub ⭐️!

A Fragno library (Fragment) has frontend, backend, and (in the future) database layers. The user's app is the full cake.

Footnotes

  1. NextAuth.js uses similar concepts and came before Better Auth, but I believe Better Auth is better known.

  2. Fragno already supports most of these frameworks. The full list of supported frameworks can be found on the Framework Support page.

  3. Lots of details omitted for brevity. Detailed documentation can be found on the Route Definition documentation page.

  4. More detailed documentation on client-side state management can be found in the Client-side State Management page.