npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

effect-tanstack-start

v0.1.0

Published

Seamlessly integrate Effect-ts with TanStack Start.

Readme

effect-tanstack-start

npm version

Seamlessly integrate Effect HttpApi with TanStack Start.

Define your API once as an Effect HttpApi contract, mount it on a TanStack Start splat route, and call it from route loaders and components using a single typed client — with zero HTTP overhead at SSR time.

How it works

Effect HttpApi gives you a typed API contract, typed handlers, and a derived typed client. TanStack Start gives you isomorphic route loaders that run on both server and client.

This library bridges them:

  • At SSR time — the client calls your handlers directly as Effect functions. No HTTP request, no serialization overhead, no URL routing. Same result as if you called the handler yourself.
  • In the browser — the client makes real HTTP requests to your API splat route, using Effect's HttpApiClient with fetch.
  • Same typed interface — both environments use the same Context.Tag. Your route loaders and components don't know or care which one they're using.

The library exports two entry points:

  • effect-tanstack-start/server — SSR client layer, API route handler (server-only, imports @tanstack/react-start/server internally)
  • effect-tanstack-start/client — HTTP client layer, shared tag, call helper (safe for both environments)

Install

npm install effect-tanstack-start
# peer dependencies
npm install effect @effect/platform @tanstack/react-start @tanstack/react-router react

Setup

1. API contract

Define your API using Effect HttpApi. This is standard Effect — nothing library-specific here.

// src/api/api-contract.ts
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
import { Schema } from "effect";

export const Todo = Schema.Struct({
  id: Schema.String,
  title: Schema.String,
  completed: Schema.Boolean,
  createdAt: Schema.DateTimeUtc,
});

export class TodoNotFound extends Schema.TaggedError<TodoNotFound>()(
  "TodoNotFound",
  { id: Schema.String },
  HttpApiSchema.annotations({ status: 404 }),
) {}

export class TodosApiGroup extends HttpApiGroup.make("todos")
  .add(HttpApiEndpoint.get("list", "/todos").addSuccess(Schema.Array(Todo)))
  .add(
    HttpApiEndpoint.get("getById", "/todos/:id")
      .setPath(Schema.Struct({ id: Schema.String }))
      .addSuccess(Todo)
      .addError(TodoNotFound),
  )
  .add(
    HttpApiEndpoint.post("create", "/todos")
      .setPayload(Schema.Struct({ title: Schema.String }))
      .addSuccess(Todo),
  ) {}

export class ApiContract extends HttpApi.make("api").add(TodosApiGroup).prefix("/api") {}

2. API implementation

Implement your handlers with HttpApiBuilder. Also standard Effect.

Important: Don't provide stateful services (those backed by Ref, database connections, etc.) inside ApiImplLive. They should come from the runtime so that both the SSR client and HTTP handler share the same instances.

// src/api/api-impl.ts
import { HttpApiBuilder } from "@effect/platform";
import { Effect, Layer } from "effect";
import { ApiContract } from "./api-contract";
import { TodosService } from "../services/todos-service";

const TodosGroupLive = HttpApiBuilder.group(ApiContract, "todos", (handlers) =>
  handlers
    .handle("list", () => Effect.flatMap(TodosService, (s) => s.list))
    .handle("getById", ({ path }) => Effect.flatMap(TodosService, (s) => s.getById(path.id)))
    .handle("create", ({ payload }) => Effect.flatMap(TodosService, (s) => s.create(payload))),
);

// TodosService is NOT provided here — it comes from the runtime
export const ApiImplLive = HttpApiBuilder.api(ApiContract).pipe(Layer.provide(TodosGroupLive));

3. Shared API client tag

Create the shared Context.Tag. This is the only thing imported by both server and client runtimes. It must not import any server-only code.

// src/services/api-client-tag.ts
import { makeApiClientTag } from "effect-tanstack-start/client";
import { ApiContract } from "@/api/api-contract";

export const ApiClient = makeApiClientTag(ApiContract);

4. Server runtime

The .server.ts suffix is important — TanStack Start's import protection automatically excludes this file from the client bundle.

// src/runtimes/server-runtime.server.ts
import { makeSsrApiClientLayer, mountApi } from "effect-tanstack-start/server";
import { Layer, Logger, ManagedRuntime } from "effect";
import { ApiContract } from "@/api/api-contract";
import { ApiImplLive } from "@/api/api-impl";
import { TodosService } from "@/services/todos-service";
import { ApiClient } from "@/services/api-client-tag";

// SSR client — calls handlers directly, no HTTP.
// Automatically forwards browser request headers (cookies, auth tokens)
// to middleware via getRequestHeaders() internally.
const SsrApiClientLive = makeSsrApiClientLayer(ApiContract, ApiImplLive, ApiClient);

// Stateful services are provided here so both the SSR client and
// HTTP handler (mountApi) share the same instances.
export const serverRuntime = ManagedRuntime.make(
  SsrApiClientLive.pipe(
    Layer.provideMerge(TodosService.Default),
    Layer.provideMerge(Logger.pretty),
  ),
);

// Handler for the API splat route
export const apiHandler = mountApi(ApiContract, {
  serverRuntime,
  apiLayer: ApiImplLive,
});

5. Client runtime

// src/runtimes/client-runtime.ts
import { makeHttpApiClientLayer } from "effect-tanstack-start/client";
import { Layer, Logger, ManagedRuntime } from "effect";
import { ApiContract } from "@/api/api-contract";
import { ApiClient } from "@/services/api-client-tag";

// HTTP client — makes fetch requests to the API splat route
const HttpApiClientLive = makeHttpApiClientLayer(ApiContract, ApiClient);

export const clientRuntime = ManagedRuntime.make(Layer.mergeAll(HttpApiClientLive, Logger.pretty));

6. Isomorphic runtime getter

Uses TanStack Start's createIsomorphicFn to pick the correct runtime at compile time. The static import of the .server.ts file is safe — import protection handles client-side exclusion.

// src/runtimes/get-runtime.ts
import { createIsomorphicFn } from "@tanstack/react-start";
import { makeCallApiPromise } from "effect-tanstack-start/client";
import { ApiClient } from "@/services/api-client-tag";
import { serverRuntime } from "./server-runtime.server";
import { clientRuntime } from "./client-runtime";

export const getRuntime = createIsomorphicFn()
  .server(() => serverRuntime)
  .client(() => clientRuntime);

export const callApiPromise = makeCallApiPromise(ApiClient, getRuntime);

7. API splat route

Mount the Effect API on a TanStack Start route. mountApi returns a single handler function — wire it into server.handlers inline so TanStack's compiler can statically analyze the route config.

// src/routes/api.$.ts
import { createFileRoute } from "@tanstack/react-router";
import { apiHandler } from "@/runtimes/server-runtime.server";

export const Route = createFileRoute("/api/$")({
  server: {
    handlers: {
      GET: apiHandler,
      POST: apiHandler,
      PUT: apiHandler,
      PATCH: apiHandler,
      DELETE: apiHandler,
      OPTIONS: apiHandler,
    },
  },
});

Important: The route config object must be written inline — not passed as an imported variable. TanStack's router generator performs static AST analysis on the config to classify routes. An opaque imported object prevents this analysis and causes runtime errors.

Usage

Route loaders

import { createFileRoute } from "@tanstack/react-router";
import { callApiPromise } from "@/runtimes/get-runtime";

export const Route = createFileRoute("/")({
  loader: () => callApiPromise((api) => api.todos.list()),
  component: Todos,
});

This works identically on server (SSR) and client (navigation). At SSR time, the handler is called directly. In the browser, it makes an HTTP request to /api/todos.

Components

const addTodo = async (title: string) => {
  await callApiPromise((api) => api.todos.create({ payload: { title } }));
};

const deleteTodo = async (id: string) => {
  await callApiPromise((api) => api.todos.remove({ path: { id } }));
};

Using Effect composition

The callback passed to callApiPromise returns an Effect, so you can compose freely:

import { Effect } from "effect";

// Combine multiple API calls
loader: () =>
  callApiPromise((api) =>
    Effect.all({
      todos: api.todos.list(),
      stats: api.dashboard.stats(),
    }),
  ),

Protected routes

Use a layout route with beforeLoad to check auth before loading child routes:

// src/routes/_authed.tsx
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { callApiPromise } from "@/runtimes/get-runtime";

export const Route = createFileRoute("/_authed")({
  beforeLoad: async ({ location }) => {
    try {
      const session = await callApiPromise((api) => api.auth.me());
      return { user: session };
    } catch {
      throw redirect({ to: "/login", search: { redirect: location.href } });
    }
  },
  component: () => <Outlet />,
});

Child routes under _authed/ are automatically protected — no per-route auth boilerplate.

Auth middleware

The SSR client automatically forwards browser request headers (cookies, Authorization tokens) to your Effect HttpApi middleware. This is done internally via TanStack Start's getRequestHeaders() — no configuration needed.

Define auth middleware in your API contract using HttpApiSecurity and HttpApiMiddleware.Tag, implement the handler, and both SSR and HTTP paths will run the same auth pipeline.

See the example app for a complete auth implementation with cookie-based sessions, login/logout, and protected routes.

API Reference

effect-tanstack-start/client

makeApiClientTag(api)

Creates a Context.Tag typed from your HttpApi contract. The tag's service type is the full HttpApiClient.Client shape with typed errors per endpoint.

Both makeSsrApiClientLayer and makeHttpApiClientLayer provide this tag with different implementations.

makeCallApiPromise(clientTag, getRuntime)

Creates a convenience function that resolves the ApiClient from the appropriate runtime and runs the effect as a Promise. Designed for use in route loaders and event handlers.

const callApiPromise = makeCallApiPromise(ApiClient, getRuntime);

// Usage — one-liner in a loader
loader: () => callApiPromise((api) => api.todos.list());

makeHttpApiClientLayer(api, clientTag, options?)

Creates a Layer that provides the ApiClient via HTTP fetch. Use this in your client runtime.

Options are passed through to HttpApiClient.make:

| Option | Type | Description | | ------------------- | ------------------------------------ | ------------------------------------------------------- | | baseUrl | string \| URL | Defaults to window.location.origin in the browser | | transformClient | (client: HttpClient) => HttpClient | Transform the underlying HTTP client (e.g. add headers) | | transformResponse | (effect: Effect) => Effect | Transform response effects (e.g. add logging) |

ClientOf<Api> (type)

Type-level utility that extracts the HttpApiClient.Client type from an HttpApi definition.

import type { ClientOf } from "effect-tanstack-start/client";
type MyClient = ClientOf<typeof ApiContract>;

effect-tanstack-start/server

makeSsrApiClientLayer(api, apiImplLayer, clientTag)

Creates a Layer that provides the ApiClient via direct handler invocation. Use this in your server runtime for zero-overhead SSR.

Internally, it builds the full API Layer runtime, extracts the HttpRouter, and creates endpoint functions that call route handlers directly — same pipeline (schema decoding, middleware, business logic, response encoding) minus HTTP transport.

Browser request headers are automatically forwarded to middleware via getRequestHeaders() from @tanstack/react-start/server.

mountApi(api, options)

Creates a request handler ({ request: Request }) => Promise<Response> for an Effect HttpApi. Assign it to every HTTP method in your splat route's server.handlers.

| Option | Type | Description | | --------------- | ---------------- | -------------------------------------- | | serverRuntime | ManagedRuntime | Your server ManagedRuntime | | apiLayer | Layer | Your composed API implementation Layer |

Stateful services

Services backed by mutable state (Ref, in-memory stores, database connections) must be provided by the runtime, not by ApiImplLive. This ensures that both the SSR client (direct handler invocation) and the HTTP handler (mountApi) share the same instances.

// Wrong — creates separate instances for SSR and HTTP
const ApiImplLive = HttpApiBuilder.api(ApiContract).pipe(
  Layer.provide(TodosGroupLive),
  Layer.provide(TodosService.Default), // new Ref created here
);

// Right — runtime provides stateful services, both paths share them
const ServerLayer = SsrApiClientLive.pipe(
  Layer.provideMerge(TodosService.Default), // single Ref instance
  Layer.provideMerge(Logger.pretty),
);

Tree-shaking and import protection

TanStack Start route files are loaded in both server and client environments. The library relies on TanStack Start's import protection to keep server code out of the client bundle:

  • server-runtime.server.ts — the .server.ts suffix triggers import protection. This file is automatically excluded from the client bundle.
  • api-client-tag.ts — only contains the shared tag. Safe for both environments.
  • get-runtime.ts — statically imports server-runtime.server.ts, but import protection mocks it on the client side.
  • api.$.ts — imports apiHandler from the .server.ts file. Same protection applies.

The library's split entry points (/server and /client) ensure that importing from effect-tanstack-start/client never pulls in server-only code like @tanstack/react-start/server.

Future goals

  • Integrate with TanStack Query and effect-query for data fetching with caching, refetching, and optimistic updates
  • Allow the user to define a route loader using an Effect generator function
  • Allow the user to define a createServerFn using an Effect generator function (without wrapping the impl in Effect.runPromise or Runtime.runPromise)
  • Distant goal: a Vite plugin that automatically code-splits an Effect HttpApi into per-route SSR chunks, transmuting Effect HttpApi routes into TanStack Start API routes and middleware

Acknowledgements

Inspired by:

  • effect-nextjs — isomorphic runtime pattern for Effect + Next.js
  • effect-query — Effect integration with TanStack Query
  • better-call — shares the same core idea: endpoints callable as direct functions or via HTTP, no reason to go through the network when you're already on the server

License

MIT