Type-Safe Fetch with Next.js, Strapi, and OpenAPI

Manuel Schoebel - Apr 2 - - Dev Community

Introduction

This blog post will teach you how to achieve type safety in a front-end application used for your Strapi backend. You can accomplish this with just a few lines of code using a REST API and the fetch function.

If you like the content in a video format, feel free to check the original version on YouTube: Type-Safe Fetch with Next.js, Strapi, and OpenAPI.

Why TypeScript anyway?

TypeScript helps you in many ways in the context of a JavaScript app. It makes it easier to consume interfaces of any type.

For example, if a component's properties are typed, it is much more straightforward to use this component. However, when developing a react component with typed properties, you know what data you can use to implement your component.
However, TypeScript can also significantly help when interacting with external services using their APIs.

When you use technologies like GraphQL, it is trivial to derive TypeScript types. A GraphQL API is created by implementing a schema. Generating the TypeScript type definitions from this schema is simple, and you do not have to do any more work than just making the GraphQL API. This is one reason why I like GraphQL so much.

Other approaches, like tRPC (TypeScript Remote Procedure Calls), already include TypeScript in their name. When developing an API, you inherently also create its types.

However, the most commonly used APIs are still simple JSON APIs (often called REST APIs). This API, in essence, gives you a URL that then returns a "whatever" JSON object.

Working with REST API could be more pleasant when you are used to fully typed APIs. You can start logging the response, or you will have to look up documentation for this API. Since you know that the documentation and API are technically not integrated, they are "out of sync" more often than not.

However, REST APIs are still the de facto standard and most used type of API, so there is a lot of tooling around them.

OpenAPI

One of these is the OpenAPI specification. In contrast to GraphQL, you do not create a schema that is your API. You first create your API in isolation, then describe it by creating a technical specification in the OpenAPI format.

From this OpenAPI specification, you can, for example, generate UIs that help you with visual documentation and use them as a sandbox environment for the API. Essentially, it is not far from a GraphQL API with its Playgrounds.

001-open-ai-specification.png

But is REST still any good?

The commonality of a REST API is also its most significant advantage. No web developer is likely not familiar with REST APIs. Often, it is impossible to use any super modern and fancy technology, especially in environments with older IT systems or complicated IT Governance processes.

Another considerable advantage of simple REST APIs is that they are more likely to pass the test of time. Technologies and their best practices are still changing so fast. And the more common and uncomplicated the basic building blocks are, the more likely they can be used even years from now.

GraphQL, for example. In web projects, you typically use GraphQL in conjunction with very complex client libraries like Apollo. These libraries do a lot of things, like normalizing data, maintaining state, and caching. This comes with a larger payload of JavaScript. But when things change, you might not want to use all of its capabilities anymore, and they can become harder to integrate and use with new approaches.

Right now, we, as client-heavy JavaScript developers, are moving back to the server again. With next.js server components and lots of caching mechanisms baked into the framework itself, some complexities on the client side become obsolete again. Also, next.js is extending the native fetch API, so if you rely on tools that implement their own data fetching, things get more complex.

The Plan

This is why I tried an uncomplicated approach that still gives you great TypeScript support and works great with your favorite headless CMS, Strapi.

Conceptually, you need to have the OpenAPI schema of your REST API, a way to generate the TypeScript type definitions from it, and a way to actually use those type definitions. Luckily, there are packages that make the whole process effortless.

Creating the OpenAPI schema in Strapi

Strapi offers an official plugin called @strapi/plugin-documentation (here). Install it to your existing Strapi project using:

  npm run strapi install documentation
Enter fullscreen mode Exit fullscreen mode

002-documentation-plugin.png

This plugin gives you two important things:

  1. It automatically generates the OpenAPI specifications for your Strapi service as a JSON file.
  2. It provides you with a Swagger UI as a visual documentation to explore and try the REST API.

In the Swagger UI you can see the endpoints for the Page collection type created inside Strapi. There are endpoints to create, read, update or delete pages automatically when you create a collection type in Strapi.

003-strapi-documentation-swagger-ui.png

This UI is generated from the OpenAPI schema file which you will be able to find in your project under.

src/extensions/documentation/documentation/1.0.0/full_documentation.json 
Enter fullscreen mode Exit fullscreen mode

In the OpenAPI specifications you can find all existing paths of the API:

004-openai-specification-for-documentation-plugin.png

And within a path you can also find a reference to its response schema, which will be very important later on:

005-response-schema-for-pages.png

When you dig deeper into the PageResponse schema you will eventually end up at the actual schema for a Page collection type:

006-actual-page-schema.png

As you can see there are some very relevant information about the Page collection type. It is of type object, well… that’s not a surprise. It does have a property path of type string and also a property blocks that has items that can be different types. A ContentHeroComponent or a ContentImageTextComponent. And those, as you might have guessed, are Strapi components which makes your blocks property a dynamic zone, just described within your OpenAPI specs.
In Strapi, the Page looks like this:

007-page-collection-type.png

So indeed, the generated OpenAPI specifications match what you defined in Strapi.
From that, you should now be able to generate the TypeScript type definitions.

Generating TypeScript Type Definitions

To do so, all you need is a library called openapi-typescript. This library takes a file with the OpenAPI specs or a URL to those and outputs your TypeScript type definitions.
Note that you want to install this in your frontend application since you will use the TypeScript type definitions there.

npm install openapi-typescript
Enter fullscreen mode Exit fullscreen mode

Once installed, you can simply add a script to your package.json that provides the path to the generated OpenAPI specs (or URL Endpoint) and the location where to output the type definitions.

In this example, the next.js frontend and the Strapi backend are in the same folder on the root level:

/project-root
         /frontend
         /backend
Enter fullscreen mode Exit fullscreen mode

The resulting scripts in the package.json are therefore:

"scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "types:generate": "openapi-typescript ../backend/src/extensions/documentation/documentation/1.0.0/full_documentation.json -o src/api/strapi.d.ts",
    "test:ts": "tsc --noEmit",
    "lint": "next lint"
  }

Enter fullscreen mode Exit fullscreen mode

Running the script is as simple as:

// in /frontend
npm run types:generate
Enter fullscreen mode Exit fullscreen mode

This command creates a strapi.d.ts file containing the TypeScript type definitions from your OpenAPI specifications file.
When you look at the generated file, you find familiar things:

008-generated-type.png

The interface for all available paths is critical down the line. As you can see, the API routes exist to create, read, update or delete the /pages route.

In the interface components of the same file, you then find the type for your Page collection type.

009-page-collection-interface.png

This way, you could already be using the TypeScript types for the Page content type you created in your Strapi backend. That is great already. As you can see, even the blocks in the dynamic zone from Strapi are typed, as are the types of components that were generated.

The schema for the components shows the configuration that is available in your Strapi backend.

The referenced ContentImageTextComponent looks like this:

010-content-image-text-component.png

From the image above, the component has attributes like text and textAlign as an enum of left or right, and more. Those are valuable types when developing the graphical representation of your backend Strapi-Component as a react component in the frontend.

Using the Types in a React Component

Now that you have generated the types, you can use them directly for your React components.
You can import the { components } from the generated type definitions. And then use the ["schemas"] you need. Here, you type the ImageText component and get the autocomplete for the props.

011-types-in-react.png

In order to receive the props in the first place, you still need to fetch the data. And of course you want to leverage the types for data fetching as well.

Using Typed Fetch

In order to use fetch in conjunction with the generated type definitions, you can use a library called openapi-fetch. This library is a small wrapper around the native fetch and consumes the output of the openapi-typescript library.

npm i openapi-fetch
Enter fullscreen mode Exit fullscreen mode

All you need to do is create the client and reference the generated types for path :

// in src/api/index.ts
import createClient from "openapi-fetch";
import type { paths } from "./strapi";

const client = createClient<paths>({
  baseUrl: "http://127.0.0.1:1337/api",
  headers: {
    Accept: "application/json",
  },
});
export { client };

Enter fullscreen mode Exit fullscreen mode

Now you can use the client to do your first data-fetching. For example, on a Next.js page, you want to fetch the data of a specific page. For example, the data of a page with id = 1.

import { client } from "@/api";

export default async function Page() {
  const pageResponse = await client.GET("/pages/{id}", {
    params: {
      path: {
        id: 1,
      },
    },
  });

  const pageData = pageResponse.data?.data?.attributes;
  return <pre>{JSON.stringify(pageData, null, 2)}</pre>;
}

Enter fullscreen mode Exit fullscreen mode

When using the typed fetch client, you can see all available paths in your client.GET(PATH... :
But also the response is typed automatically for you:

012-autocomplete-paths.png

Here you can see the autocompletion of the actual data of your Page which includes the path attribute.

Using qs to Provide Query Strings

You will notice that the data fetching above does not return the data of the assigned blocks. This is because the Strapi REST API requires you to specify some specific query parameters to instruct the REST API to include those relations in the response.
With libraries, query parameters can be easier to use and maintain. For example, the GET request for including fields and using population might look like this

GET /api/articles?fields\[0]=title&fields[1]=slug&populate[headerImage\][fields]\[0]=name&populate[headerImage\][fields][1]=url
Enter fullscreen mode Exit fullscreen mode

That would mean a bit of string concatenation. Luckily, Strapi suggest a library called qs to make this a bit more streamlined:

const qs = require("qs");
const query = qs.stringify(
  {
    fields: ["title", "slug"],
    populate: {
      headerImage: {
        fields: ["name", "url"],
      },
    },
  },
  {
    encodeValuesOnly: true, // prettify URL
  },
);

await request(`/api/articles?${query}`);

Enter fullscreen mode Exit fullscreen mode

Since you are using fetch not directly but openapi-fetch you do not really pass a path that includes the query string as a simple string to fetch. The syntax for query parameters is like this:

const pages = await client.GET("/pages", {
  params: {
    query: {
      //..
    },
  },
});

Enter fullscreen mode Exit fullscreen mode

Though by default you cannot really just add the object from the Strapi examples using qs . For example, if you would want to add the qs style query to filter pages for the path / and also populate all components used in the dynamic zone field blocks , you would do the following query:

const pages = await client.GET("/pages", {
  params: {
    query: {
      filters: {
        // @ts-ignore - openapi generated from strapi results in Record<string, never>
        // https://github.com/strapi/strapi/issues/19644
        path: {
          $eq: path,
        },
      },
      // @ts-ignore
      populate: {
        blocks: { populate: "*" },
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Note: The @ts-ignore annotations are necessary right now due to the fact how the OpenAPI documentation is generated by the Strapi documentation plugin.

If you do it like this you will get an error like this:

013-type-error.png

Because you cannot pass complex objects as a query to openapi-fetch out of the box, you can override the part responsible for converting the query object that is passed to the client. So what you want is for the so-called querySerializer of opanapi-fetch to use qs, and you can do so when the client gets created:

import createClient from "openapi-fetch";
import type { paths } from "./strapi";
import qs from "qs";

const client = createClient<paths>({
  baseUrl: "http://127.0.0.1:1337/api",
  headers: {
    Accept: "application/json",
  },
  querySerializer(params) {
    console.log("querySerializer", params, qs.stringify(params));
    return qs.stringify(params, {
      encodeValuesOnly: true, // prettify URL
    });
  },
});
export { client };
Enter fullscreen mode Exit fullscreen mode

And with that the query string is generated by passing the query object to qs. So with that, you can create the queries exactly as shown in the Strapi documentation itself.

Client Side Data Fetching

What you have seen now works great especially when you are fetching data on the server, like in React Server Components using Next.js. But data fetching on the client side is often a bit more involved. At least what you want is for example some data loading indication and to know when the data is actually available in the client.

A library that fullfills this need, but still has a small payload in terms of bundle size, is react-query from tanstack.

We will be loading Comments, which is also a data type defined in your Strapi backend.
A simple approach could be to create a client-side react component that uses react-query to fetch the data.

"use client";
import { getComments } from "@/api/getComments";
import { Headline } from "@/components/elements/Headline";
import { useQuery } from "@tanstack/react-query";
import React from "react";

export interface IComments {}

function Comments({}: IComments) {
  const { isPending, data } = useQuery({
    queryKey: ["getComments"],
    queryFn: () => getComments(),
  });

  return (
    <div>
      <Headline variant="h2">Comments</Headline>
      {isPending && <p>Loading comments...</p>}
      {data &&
        data.map((comment) => (
          <div key={comment.id} className="my-6">
            <Headline variant="h3">{comment.attributes?.username}</Headline>
            <p>{comment.attributes?.comment}</p>
          </div>
        ))}
    </div>
  );
}

export { Comments };

Enter fullscreen mode Exit fullscreen mode

In react-query, you need to pass a function to the queryFn that returns a promise. As you can see, you do not use openapi-fetch here anywhere. But still the data is all typed by the type definitions generated directly from your Strapi backend.

014-autocomplete-types.png

And this is simply because in the queryFn you passed to react-query, you are leveraging out typed fetch approach again.

import { client } from ".";
export async function getComments() {
  const comments = await client.GET("/comments", {
    cache: "no-cache",
  });
  return comments?.data?.data || [];
}

Enter fullscreen mode Exit fullscreen mode

As you can see, you are again using the typescript-fetch client and return the typed data. And with that you have everything typed, also on the client side.

Conclusion

As you can see there is a way to achieve a meaningful grade of type safety or type-safe fetch with Next.js without much effort and especially without adding lots of complicated technologies and bundle size.
Using a simple fetch that leverages the generated type definitions based on the also automatically generated OpenAPI specifications from within Strapi is very little work but provides tons of values.

Not only for the data fetching part. But also for developing react components that reflect the building blocks coming from your headless CMS.
And the best thing is, that all of this is achievable in any environment where you cannot use the latest and greatest of tools available in JavaScript land.

Resources

Connect with Manuel!

Stay up to date with my latest tutorials on my website and feel free to connect with me on Twitter and YouTube.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .