Encore clients in Next.js

The dream of typesafe contracts.

Published:

The combination of Encore and Next.js 13's app directory is becoming a hugely productive combination for me. Fetching data in server-side rendered components on Next.js is a dream when combined with Encore’s TypeScript client generation.

I’ve found a few helpful additions to the generated client can make the whole experience a bit more comfortable. For context, here’s what initialising a client (for local testing) looks like:

// lib/client.ts
import Client, { Local } from "@/lib/client.gen";

const client = new Client(Local);
export default client;

Grouping SSR requests

The generated client already offers you a way to modify the way requests are called: providing a custom fetcher.

// A fetcher is the prototype for the inbuilt Fetch function
export type Fetcher = typeof fetch;

So as long as it has the same signature as the Fetch API, we can use it.

Here’s what it would look like to wrap fetch so we can add a custom header:

// lib/client.ts
import Client, { Fetcher, Local } from "@/lib/client.gen";

const fetcher: Fetcher = (input, init = {}) => {
  const headers = new Headers(init.headers);
  headers.append("X-Example-Header", "value");

  return fetch(input, { ...init, headers });
};

const client = new Client(Local, { fetcher });
export default client;

With Encore’s schema definitions though, any headers required for a particular request should already be expected as part of the client’s params. So generally this isn’t helpful for individual requests.

There’s one header that Encore makes use of that I’ve found helpful here though: X-Request-ID.

With Encore v1.11.0 it became possible to filter traces based on various criteria. Most are already provided by Encore: Duration, User ID, Trace ID. X-Request-ID allows filtering on an externally defined request ID set as a header.

So here’s what I’m doing: group requests made by Next.js when server-rendering pages by providing a consistent X-Request-ID per request.

// lib/client.ts
import Client, { Fetcher, Local } from "@/lib/client.gen";
import { nanoid } from "nanoid/non-secure";
import { cache } from "react";

/**
 * Generate a random string to be used as a request ID.
 *
 * Result is cached for the duration of a single request.
 */
export const useRequestID = cache(nanoid);

/**
 * Retrieve a configured client.
 *
 * Result is cached for the duration of a single request.
 */
export const useClient = cache(() => {
  const requestID = useRequestID();

  const fetcher: Fetcher = (input, init = {}) => {
    const headers = new Headers(init.headers);
    headers.append("X-Request-ID", requestID);

    return fetch(input, { ...init, headers });
  };

  return new Client(Local, { fetcher });
});

The trick here is the cache function, which can be used for per-request caching.

You can use cache() to deduplicate data fetches on a per-request basis. If a function instance with the same arguments has been called before, anywhere in the server request, then we can return a cached value.

Note that it says: ‘same arguments’. So we can provide a function that optionally takes our authentication params:

// lib/client.ts
import Client, { ClientOptions, Fetcher, Local } from "@/lib/client.gen";
import { nanoid } from "nanoid/non-secure";
import { cache } from "react";

/**
 * Generate a random string to be used as a request ID.
 *
 * Result is cached for the duration of a single request.
 */
export const useRequestID = cache(nanoid);

/**
 * Retrieve a configured client.
 *
 * Result is cached for the duration of a single request.
 */
export const useClient = cache((auth?: ClientOptions["auth"]) => {
  const requestID = useRequestID();

  const fetcher: Fetcher = (input, init = {}) => {
    const headers = new Headers(init.headers);
    headers.append("X-Request-ID", requestID);

    return fetch(input, { ...init, headers });
  };

  return new Client(Local, { fetcher, auth });
});

Every time we call useClient without the authentication params then we get the same anonymous client. And calling it with the authentication params gives us the same authenticated client.

As useRequestID is also cached, both clients will have the same X-Request-ID header and it will be simple to identify all the requests involved in rendering a single page regardless of whether they were made with or without a user’s credentials.