Next, Effect and (Async) Context

Have you ever encountered mysterious server-side rendering errors when using Effect-TS with Next.js? This article explores a common but tricky issue that occurs when Effect's runtime system conflicts with Next.js's context propagation mechanism, specifically when trying to access dynamic request data (like headers through next/headers, or cookies via next/cookies).

error

Recently I was working on a React component. This component would basically take in some tRPC procedure (query) as prop, wrap the inner subcomponents inside Suspense boundary, inside that boundary get data from the procedure via tanstack-query's useSuspenseQuery, and do some stuff with the data request result.

Then component looked more or less like this:

export const Component = <
    TDef,
    Procedure extends DecorateQueryProcedure<TDef>,
>({
    procedure
}: {
    procedure: Procedure
}) => {
    const { data: definition } = useSuspenseQuery(
        procedure.queryOptions()
    );

    console.log('definition', definition);

    return <div>test</div>
};

And I would use it like this in a next.js page:

const Page = () => {
    const trpc = useTRPC();
    const dataLoader = trpc.dataStore.getDataForSomething;

    return <Component procedure={dataLoader} />;
};

export default Page;

Nothing fancy here, the procedure would just contact a backend service and get some data:

export const getDataForSomething = async () => {
    const client = await getAPIClient();
    const response = await client.GET('url/path/to/data');

    if (response.ok) {
        return response.data;
    }

    throw new Error(response.error);
};

I thought there would be no problem, and the data indeed appeared rendered on the web. But there was a strange error log in the browser console: error

Uncaught Error: Switched to client rendering because the server rendering errored:

null
    at TRPCClientError.from (file:///var/www/html/sandbox/.next/server/chunks/ssr/8e8e3_@trpc_client_dist_f6e7fb._.js:92:20)

Server logs were not helping either:

⨯ [Error [TRPCClientError]: null] {
2025-10-04 13:40:59:   cause: undefined,
2025-10-04 13:40:59:   shape: [Object],
2025-10-04 13:40:59:   data: [Object],
2025-10-04 13:40:59:   meta: [Object],
2025-10-04 13:40:59:   digest: '1829391229'
2025-10-04 13:40:59: }

The Investigation

I tried a few things and found out that the error is caused by apiClient. The story has a few interesting points worth remembering, which I will explain.

The true cause of this error originates from the internals of Effect and Next.js and the way they integrate with each other.

The apiClient is a wrapper for createClient from openapi-fetch that calls next/headers for extracting dynamic request-time values from HTTP headers. Now next/headers is a Next.js API that is made for reading the HTTP headers of incoming requests.

import { headers } from 'next/headers';

export default async function Page() {
    const headersList = await headers();
    const userAgent = headersList.get('user-agent');
}

The apiClient uses it to get some custom headers (like X-* headers) to be forwarded to backend services.

Now the important part is that the apiClient is implemented as an Effect. To get the resulting object, you would run it like this:

export const getAPIClient = async () => {
    return apiClient.pipe(Effect.runPromise);
};

How Next.js Server Context Works

Next.js uses Node.js AsyncLocalStorage under the hood to provide functions like headers(), cookies(), etc. This is essentially a way to have "context" that flows through async operations without explicitly passing parameters through every function call:

// Simplified version of what Next.js does internally
const asyncLocalStorage = new AsyncLocalStorage();

// Next.js sets up the context when handling a request
asyncLocalStorage.run(requestData, async () => {
    // Inside here, headers() can access requestData
    await yourServerComponent();
});

// headers() implementation (simplified)
function headers() {
    const store = asyncLocalStorage.getStore();
    if (!store) throw new Error('headers() can only be called in server context');
    return store.headers;
}

Why It Fails Inside Effect.runPromise

The problem is that Effect-TS creates its own execution model with its own fiber/runtime system. When you call Effect.runPromise, you're essentially creating a boundary between Node.js's async context and Effect's runtime:

  • Creating a new Effect fiber: Effect manages its own lightweight threads (fibers) for concurrency
  • Different execution context: The fiber runs in Effect's runtime, not directly in Node.js's async context
  • Context isolation: Node.js AsyncLocalStorage doesn't automatically propagate through Effect's runtime because Effect doesn't inherit the async context when creating new fibers

This means that when your Effect tries to call headers() inside Effect's runtime, the AsyncLocalStorage.getStore() call returns undefined because the context was lost during the transition to Effect's execution model.

Possible Solutions

1. Capture the Context Before Calling Effect and Pass It to It

export const getAPIClient = async () => {
    try {
        const correlationID = await getCorrelationID();
        return createApiClient(correlationID).pipe(Effect.runPromise);
    } catch (error) {
        console.error('Error getting API client', error);
        throw error;
    }
};

Or get the dynamic data from HTTP request in tRPC's context.

2. Provide Headers as Effect Service through layer

We might want to use the apiClient component as an external library in different frameworks, like TanStack Start, where we would get headers using getRequestHeader. If apiClient were a reusable component, we would probably want to utilize IoC - Inversion of Control to remove the hard dependency on Next.js.

We can create an Effect service that would have a similar API to the Next.js headers.

import { Effect, Layer } from 'effect';

export class Headers extends Effect.Tag('Headers')<
    Headers,
    {
        get: (name: string) => string | null;
        entries: () => [string, string][];
        has: (name: string) => boolean;
        keys: () => string[];
        values: () => string[];
    }
>() {}

This Headers tag is only an abstract interface that could be implemented in any backend, but we need a Next.js implementation layer that we can provide to the app:

import { headers } from 'next/headers';

export const NextHeaders = (headersStore: Awaited<ReturnType<typeof headers>>) =>
    Layer.succeed(
        Headers,
        Headers.of({
            get: (name: string) => headersStore.get(name),
            entries: () => Array.from(headersStore.entries()),
            has: (name: string) => headersStore.has(name),
            keys: () => Array.from(headersStore.keys()),
            values: () => Array.from(headersStore.values()),
        })
    );

And now we can provide the Next.js headers service as a layer to the apiClient Effect:

export const getAPIClient = async () => {
    const headersStore = await headers();
    return apiClient.pipe(Effect.provide(NextHeaders(headersStore)), Effect.runPromise);
};

This way, we can have an apiClient Effect that is decoupled from Next.js and reusable in different JavaScript backend frameworks.

Conclusion

The integration between Effect-TS and Next.js can be tricky when it comes to context propagation. The key issue is that Effect's runtime system doesn't automatically inherit Node.js's AsyncLocalStorage context, which Next.js relies on for functions like headers().

The two main solutions are:

  1. Extract context early: Capture the needed data (like headers or correlation IDs) before entering Effect's runtime
  2. Use Effect's dependency injection: Create abstract services and provide concrete implementations through layers

Both approaches have their merits. The first is simpler for quick fixes, while the second provides better architecture for reusable components across different frameworks.

Understanding this interaction is crucial for anyone building server-side applications that combine Effect-TS with Next.js or similar frameworks that rely on async context propagation.