DEV Community

Oleh Zelinskyi
Oleh Zelinskyi

Posted on

Automatic TypeScript Type Generation in Next.js + Apollo GraphQL + Strapi

Automatic TypeScript Type Generation in Next.js + Apollo GraphQL + Strapi

Typed code is almost always cheaper to maintain and faster to build than its “untyped” alternatives. Fewer hidden bugs, better editor hints, safer refactors — all of that saves hours, and sometimes even your Friday evenings.

TypeScript has long been the de‑facto standard in the JS ecosystem. But when a project works with an API — especially GraphQL — another problem appears: manually maintaining types for schemas, queries, and responses. It gets old fast, often lags behind the real schema, and turns into a source of small, annoying bugs.

The logical solution is automatic type generation. Configure it once — and you get up‑to‑date types directly from the schema and your operations.

In this article, I’ll show a practical approach to automatic type generation using GraphQL Code Generator. We’ll walk through a real scenario and set everything up so types update with almost no developer involvement.

Demo project idea

For the demo, we’ll use a small example split into two repositories:

  • a client app on Next.js
  • a backend on Strapi CMS

Data exchange will happen via GraphQL, and automatic type generation via codegen.

All code is available in two public repositories:

The data model is simple: cars and their owners. That’s enough to demonstrate:

  • working with a GraphQL schema
  • generating types
  • typing queries and mutations
  • using the generated types in client code

We’ll write a few queries and mutations — just enough to see the practical value without drowning the demo in extra details.

Installing the Next.js client app

At the time this example was written, it used:

  • Node.js — 20.19.3
  • pnpm — 10.12.4

Let’s start with the client. Create a standard Next.js boilerplate:

pnpm create next-app@latest client-app
Enter fullscreen mode Exit fullscreen mode

During project initialization:

  • enable TypeScript
  • keep App Router
  • you can accept the recommended settings (ESLint, src-dir, etc.)

In this example we’ll work with App Router — it pairs well with server components and the modern rendering model.

Connecting Apollo GraphQL to Next.js

The client will talk to the backend via GraphQL, so we’ll use Apollo Client.
Because Next.js uses React Server Components, the standard Apollo setup isn’t enough — you need an adapter for server rendering.

Install the integration package:

pnpm add @apollo/client-integration-nextjs
Enter fullscreen mode Exit fullscreen mode

It simplifies using Apollo Client with server components and avoids manual cache/context wiring.

At this stage the base client setup is ready — we’ll return to configuring the GraphQL client a bit later.

Installing Strapi as the backend

Now let’s set up the server side with Strapi CMS.

Create a new project:

pnpm dlx create-strapi@latest my-strapi-project
Enter fullscreen mode Exit fullscreen mode

During installation:

  • choose the default settings
  • skip Strapi Cloud — we don’t need it for this example

Start the server:

pnpm run dev
Enter fullscreen mode Exit fullscreen mode

After startup, create an admin user and sign in to the admin panel.

By default, Strapi works via REST. We need GraphQL, so add the official plugin:

pnpm add @strapi/plugin-graphql
Enter fullscreen mode Exit fullscreen mode

After installation, open:

http://localhost:1337/graphql

If you see GraphQL Playground / Apollo Studio — everything is configured correctly.

Creating Content Types in Strapi

In the demo project we’ll use three entities:

  • Car — we’ll create from scratch
  • Owner — we’ll use Strapi’s built-in User model
  • Media — Strapi’s standard model for files

Collection type setup

In Content Builder, create a new collection type Car and add fields:

name — text

model — text

year — number or text

image — media

owners — relation many-to-many → User
Enter fullscreen mode Exit fullscreen mode

In the User model (which we’ll treat as Owner), add a field:

fullname — text
Enter fullscreen mode Exit fullscreen mode

Access configuration (Permissions)

To keep the example simple, we won’t use authorization, so we need to open public access for read and write operations.

In the Strapi admin panel go to:

Settings → Users & Permissions Plugin → Roles → Public

Grant the following permissions:

Car:
    - find
    - findOne
    - create
    - update

User:
    - find
    - findOne

Media:
    - find
    - findOne
    - upload
Enter fullscreen mode Exit fullscreen mode

Test data

Before moving to the client, create a few records for Car and User.

Then validate your queries in GraphQL Playground — make sure data is returned correctly. This will be the foundation for the next step: wiring up codegen and generating types from the schema.

Fetching the car list on the client: your first GraphQL query

Let’s start simple — fetch a list of cars from the backend.

To do that, we’ll create a separate GraphQL document. Codegen will use these documents to generate types and ready-to-use typed query objects.

I recommend keeping all GraphQL operations in a dedicated folder — it makes navigation and generator configuration easier.

Create the following document at app/graphql/Cars.query.gql:

query Cars {
  cars {
    image {
      url
    }
    documentId
    model
    name
    year
    owners {
      fullname
      documentId
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

What matters here

This isn’t just a query — it’s a data contract used for type generation.

Codegen reads:

  • the query name (Cars)
  • the response shape
  • nested fields

Types are generated exactly for this structure. If you add or remove a field, types will change automatically after you re-run generation.

Configuring GraphQL Code Generator

Now let’s enable type generation. We’ll use GraphQL Code Generator — a tool that:

  • reads the GraphQL schema
  • analyzes your .gql documents
  • generates types and typed documents for the client

In the root of the client project, create codegen.ts and put this configuration inside:

import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  overwrite: true,
  schema: "http://localhost:1337/graphql/",
  documents: "app/graphql/**/*.gql",
  generates: {
    "generated/": {
      preset: "client",
      plugins: [],
    },
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

A few notes on the config:

  • overwrite: true — rewrites generated files on each run
  • schema — the GraphQL endpoint where the current schema is fetched from
  • documents — where to find your queries and mutations
  • preset: "client" — generates:
    • TypeScript types
    • typed DocumentNodes
    • ready helpers for client operations
  • generated/ — output folder (better not to mix with handwritten code)

Important: the Strapi server must be running during generation, otherwise the schema can’t be fetched.

Add an npm script to generate types

For convenience, add a script to package.json:

{
  "scripts": {
    // .. other script
    "codegen": "graphql-codegen --config codegen.ts"
  }
}
Enter fullscreen mode Exit fullscreen mode

Run the new command:

pnpm run codegen
Enter fullscreen mode Exit fullscreen mode

After it finishes, you’ll see a generated/ folder containing:

  • shared schema types
  • types for your queries, mutations, fragments, etc.
  • ready GraphQL documents

What codegen generated

Open generated/graphql.ts.

Near the end you’ll find something like:

export const CarsDocument = {
  kind: "Document",
  definitions: [{ kind: "OperationDefinition", ... }],
};
Enter fullscreen mode Exit fullscreen mode

This is a ready, typed query document.
You don’t write it by hand — it stays in sync with the schema and your .gql file.

This is what we’ll use with Apollo Client.

Using a generated typed query in Next.js

Now let’s use the query on a page.
Example for app/page.tsx:

import { getClient } from "./apollo-client";
import { CarsDocument } from "@/generated/graphql";
import Link from "next/link";

export default async function Home() {
  const carsResponse = await getClient().query({
    query: CarsDocument,
  });

  const cars = carsResponse.data?.cars || [];

  return (
    <main className="p-6 bg-gray-50 min-h-screen">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-3xl font-bold text-gray-800">List of Cars</h1>
        <Link
          href="/cars/create"
          className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
        >
          Create new car
        </Link>
      </div>

      <ul className="list-none space-y-4 max-w-2xl mx-auto">
        {cars.map((car) => (
          <li
            key={car?.documentId}
            className="p-4 bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-shadow"
          >
            <Link
              href={`/cars/${car?.documentId}`}
              className="text-blue-600 hover:text-blue-800 font-medium underline"
            >
              {car?.name}{car?.model} ({car?.year}) — Owners:{" "}
              {car?.owners?.map((owner) => owner?.fullname).join(", ") ||
                "None"}
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why are there no manual types here?

We didn’t write a single TypeScript interface — and that’s the point. TypeScript infers the response type from:

CarsDocument → CarsQuery → result.data

If you hover carsResponse, the editor will show an inferred type like:

ApolloClient.QueryResult<CarsQuery>
Enter fullscreen mode Exit fullscreen mode

Which means:

  • the response structure is typed
  • fields autocomplete correctly
  • field-name mistakes are caught at compile time
  • schema changes → re-run codegen → updated types
  • no manual model sync and no duplicated types

Single car page: a parameterized GraphQL query

A list is nice, but you usually need a details page. Let’s add a dynamic route and a typed query to fetch a single car by documentId.

With Next.js App Router, this is done via a square-bracket segment.

Create app/cars/[carID]/page.tsx:

carID is the route param we’ll pass into the GraphQL query as a variable.

Create a parameterized GraphQL query

In the GraphQL documents folder, create app/graphql/Car.query.gql:

query Car($documentId: ID!, $status: PublicationStatus) {
  car(documentId: $documentId, status: $status) {
    model
    name
    image {
      url
    }
    documentId
    year
    owners {
      fullname
      documentId
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

What’s happening here

  • Car — the operation name (types are derived from it)
  • $documentId: ID! — required parameter. The document identifier
  • $status — optional parameter to filter between draft and published

The response shape is explicit — types will be generated for it. A key detail: not only the data is typed, but also the query variables.

Re-generate types

After adding a new .gql document, re-run the generator:

pnpm run codegen
Enter fullscreen mode Exit fullscreen mode

After that, generated files will include:

  • CarQuery — response type
  • CarQueryVariables — variables type
  • CarDocument — ready typed query document

Implement the car details page

Now use the generated document inside the page server component.

// app/cars/[carID]/page.tsx:

import { getClient } from "../../apollo-client";
import { CarDocument } from "@/generated/graphql";
import Link from "next/link";

interface PageProps {
  params: Promise<{ carID: string }>;
}

export default async function CarPage({ params }: PageProps) {
  const { carID } = await params;

  const carResponse = await getClient().query({
    query: CarDocument,
    variables: { documentId: carID },
  });

  const car = carResponse.data?.car;

  if (!car) {
    return (
      <main className="p-6 bg-gray-50 min-h-screen">
        <div className="max-w-2xl mx-auto text-center">
          <h1 className="text-3xl font-bold text-gray-800 mb-4">
            Car Not Found
          </h1>
          <p className="text-gray-600">
            The requested car could not be found.
          </p>
          <Link
            href="/"
            className="text-blue-600 hover:text-blue-800 underline mt-4 inline-block"
          >
            Back to Cars List
          </Link>
        </div>
      </main>
    );
  }

  return (
    <main className="p-6 bg-gray-50 min-h-screen">
      <div className="max-w-2xl mx-auto bg-white border border-gray-200 rounded-lg shadow-sm p-6">
        <h1 className="text-3xl font-bold text-gray-800 mb-6">
          {car.name}
        </h1>

        {car.image?.url && (
          <div className="mb-6">
            <img
              src={`http://localhost:1337${car.image.url}`}
              alt={`${car.name} image`}
              className="w-full h-64 object-cover rounded-lg"
            />
          </div>
        )}

        <div className="space-y-4 text-gray-800">
          <div>
            <strong>Model:</strong> {car.model}
          </div>

          <div>
            <strong>Year:</strong> {car.year}
          </div>

          <div>
            <strong>Document ID:</strong> {car.documentId}
          </div>

          <div>
            <strong>Owners:</strong>
            {car.owners && car.owners.length > 0 ? (
              <ul className="list-disc list-inside mt-2 space-y-1">
                {car.owners.map((owner) => (
                  <li key={owner?.documentId}>
                    {owner?.fullname} (ID: {owner?.documentId})
                  </li>
                ))}
              </ul>
            ) : (
              <span className="ml-2 text-gray-600">None</span>
            )}
          </div>
        </div>

        <div className="mt-6">
          <Link
            href="/"
            className="text-blue-600 hover:text-blue-800 underline"
          >
            Back to Cars List
          </Link>
        </div>
      </div>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

What we got thanks to codegen

In this example there isn’t a single handwritten TypeScript type for:

  • the server response
  • the car structure
  • the query variables

TypeScript infers types from:

CarDocument → CarQuery → Apollo query result
Enter fullscreen mode Exit fullscreen mode

Practical impact:

  • autocomplete for all fields
  • compile-time variable validation
  • no duplicated interfaces
  • single source of truth — GraphQL schema + .gql documents
  • backend changes → re-run codegen → up-to-date types

This becomes especially noticeable on larger schemas, where manual type maintenance quickly turns into a separate job.

Creating a car: mutations, types, and a single Apollo Client without client hooks

Queries look very “human”: write a .gql, run codegen — get a typed document and a typed response.

What about mutations?

This usually raises common questions:

  • “Mutations are typically executed on user actions (so, in client components). Do I need a separate Apollo Client?”
  • “Do I need to generate React hooks like useQuery/useMutation and restructure the app?”
  • “Do I have to move all GraphQL work to the client?”

You can. But you don’t have to.

In this demo we’ll take a simpler route: keep a single Apollo Client that runs on the server, and from the client we’ll call custom Next.js API routes via fetch.

Pros:

  • one Apollo Client — no duplicated config
  • client components stay simple
  • codegen continues to type documents and responses
  • easy to mix GraphQL + REST (we’ll need REST for upload)

Con: fetch() returns “raw” JSON, so you have to type the boundary manually (when parsing the response). That’s a reasonable tradeoff for simplicity.

CreateCar mutation: define the GraphQL document

Create app/graphql/Car.mutation.gql:

mutation CreateCar($data: CarInput!) {
  createCar(data: $data) {
    documentId
  }
}
Enter fullscreen mode Exit fullscreen mode

What matters in this mutation

  • $data: CarInput! — Strapi expects an input object.

We only return documentId — that’s enough to:

  • create a Car
  • upload an image
  • link Media to Car via an update

Owners query: data for a select in the form

To create a car with owners, we need the list of owners (Users).

Create app/graphql/Owners.query.gql:

query Owners {
  usersPermissionsUsers {
    fullname
    documentId
  }
}
Enter fullscreen mode Exit fullscreen mode

Generate types

After adding new documents, run codegen:

pnpm run codegen
Enter fullscreen mode Exit fullscreen mode

Now generated/graphql.ts will include:

OwnersDocument, OwnersQuery
CreateCarDocument, CreateCarMutation
CreateCarMutationVariables
Enter fullscreen mode Exit fullscreen mode

Create page: server fetches owners, client renders the form

Create app/cars/create/page.tsx and place this code inside:

import { getClient } from "@/app/apollo-client";
import CreateCarForm from "./CreateCarForm";
import { OwnersDocument } from "@/generated/graphql";

export default async function CreateCarPage() {
  const owners = await getClient().query({
    query: OwnersDocument,
  });

  return (
    <main className="p-6 bg-gray-50 min-h-screen">
      <CreateCarForm ownersData={owners.data?.usersPermissionsUsers ?? []} />
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why is this convenient?

CreateCarPage is a React Server Component, so it runs on the server. The server makes the GraphQL request via getClient() and passes the data down.
CreateCarForm is a client component — it handles user actions and submits data.

This splits responsibilities cleanly:

  • server — GraphQL, types, backend access
  • client — form, inputs, UX

Client form component

In the same folder, create app/cars/create/CreateCarForm.tsx:

"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { OwnersQuery } from "@/generated/graphql";

interface CreateCarFormProps {
  ownersData: OwnersQuery["usersPermissionsUsers"];
}

export default function CreateCarForm({ ownersData }: CreateCarFormProps) {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();
  const [form, setForm] = useState({
    name: "",
    model: "",
    year: "",
    owners: [] as string[],
    image: null as File | null,
  });

  const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
    e.preventDefault();
    // todo Submit Logic
  };

  const handleOwnerChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const selectedOptions = Array.from(
      e.target.selectedOptions,
      (option) => option.value,
    );
    setForm({ ...form, owners: selectedOptions });
  };

  const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0] || null;
    setForm({ ...form, image: file });
  };

  return (
    <div className="max-w-2xl mx-auto bg-white border border-gray-200 rounded-lg shadow-sm p-6">
      <h1 className="text-3xl font-bold text-gray-800 mb-6">Create New Car</h1>

      <form onSubmit={handleSubmit} className="space-y-4 text-gray-800">
        <div>
          <label
            htmlFor="name"
            className="block text-sm font-medium text-gray-700"
          >
            Name
          </label>
          <input
            type="text"
            id="name"
            value={form.name}
            onChange={(e) => setForm({ ...form, name: e.target.value })}
            required
            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
          />
        </div>

        <div>
          <label
            htmlFor="model"
            className="block text-sm font-medium text-gray-700"
          >
            Model
          </label>
          <input
            type="text"
            id="model"
            value={form.model}
            onChange={(e) => setForm({ ...form, model: e.target.value })}
            required
            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
          />
        </div>

        <div>
          <label
            htmlFor="year"
            className="block text-sm font-medium text-gray-700"
          >
            Year
          </label>
          <input
            type="text"
            id="year"
            value={form.year}
            onChange={(e) => setForm({ ...form, year: e.target.value })}
            required
            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
          />
        </div>

        <div>
          <label
            htmlFor="owners"
            className="block text-sm font-medium text-gray-700"
          >
            Owners
          </label>
          <select
            id="owners"
            multiple
            value={form.owners}
            onChange={handleOwnerChange}
            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
          >
            {ownersData.map((owner) => (
              <option key={owner?.documentId} value={owner?.documentId}>
                {owner?.fullname}
              </option>
            ))}
          </select>
          <p className="text-sm text-gray-500 mt-1">
            Hold Ctrl (or Cmd) to select multiple owners.
          </p>
        </div>

        <div>
          <label
            htmlFor="image"
            className="block text-sm font-medium text-gray-700"
          >
            Car Image
          </label>
          <input
            type="file"
            id="image"
            accept="image/*"
            onChange={handleFileChange}
            className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
          />
          {form.image && (
            <p className="text-sm text-gray-500 mt-1">
              Selected: {form.image.name}
            </p>
          )}
        </div>

        <div className="flex justify-end space-x-4">
          <Link
            href="/"
            className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600"
          >
            Cancel
          </Link>
          <button
            type="submit"
            disabled={isPending}
            className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 disabled:opacity-50"
          >
            {isPending ? "Creating..." : "Create Car"}
          </button>
        </div>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Where is the “typing” here? This line is very telling:

ownersData: OwnersQuery["usersPermissionsUsers"];
Enter fullscreen mode Exit fullscreen mode

We don’t create custom interfaces — we extract the type directly from the generated query.
So: schema → gql → codegen → type → props.

API route to create a Car via the server-side Apollo Client

Now let’s implement a Next endpoint that calls the CreateCar mutation.
This is the “trick”: the client knows nothing about Apollo Client — it just calls an API route.

Create app/api/car/route.ts

import { getClient } from "../../apollo-client";
import { CreateCarDocument } from "@/generated/graphql";

export async function POST(request: Request) {
  try {
    const body = await request.json();

    const client = getClient();

    const result = await client.mutate({
      mutation: CreateCarDocument,
      variables: { data: body },
    });

    return Response.json({ success: true, data: result.data });
  } catch (error) {
    console.error("Error creating car:", error);
    return Response.json(
      { success: false, error: (error as Error).message },
      { status: 500 },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

A few notes on the code:

  • request.json() reads the payload from the form.
  • variables: { data: body } — the mutation expects $data: CarInput!.
  • result.data is typed in the route file, but that type information is lost after a fetch call.

Call the API route from the form

Add a call to /api/car in handleSubmit:

const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
  e.preventDefault();

  startTransition(async () => {
    try {
      const response = await fetch("/api/car", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          name: form.name,
          model: form.model,
          year: form.year,
          owners: form.owners,
        }),
      });

      const json = await response.json();

      const data: CreateCarMutation = json.data;

      if (data?.createCar?.documentId) {
        // Todo: Upload photo
        // Todo: Update car
        // Todo: Redirect to [carID] page
      } else {
        router.push("/");
      }
    } catch (error) {
      console.error("Error creating car:", error);
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

Why do we have to write the type explicitly here? fetch() knows nothing about GraphQL types — it returns “plain JSON”. So after response.json() we do:

const data: CreateCarMutation = json.data;
Enter fullscreen mode Exit fullscreen mode

This is a common pattern: type the I/O boundary, and then use types within the rest of your logic.

Image upload: you can’t avoid REST here

At the time this example was written, the Strapi GraphQL plugin doesn’t provide a convenient mutation for file uploads.
So uploads have to go through the REST endpoint:

POST http://localhost:1337/api/upload
Enter fullscreen mode Exit fullscreen mode

Update handleSubmit so that after creating the Car, we upload the file:

const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
  e.preventDefault();

  startTransition(async () => {
    try {
      const response = await fetch("/api/car", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          name: form.name,
          model: form.model,
          year: form.year,
          owners: form.owners,
        }),
      });

      const json = await response.json();
      const data: CreateCarMutation = json.data;

      if (data?.createCar?.documentId) {
        const formData = new FormData();

        if (form.image) {
          formData.append("files", form.image);
        }

        const fileUploadResponse = await fetch(
          "http://localhost:1337/api/upload",
          {
            method: "POST",
            body: formData,
          },
        );

        const fileUpload = await fileUploadResponse.json();
        const fileDocId = fileUpload[0]?.id;

        if (fileDocId) {
          // Todo: Update Car with new media
          // Todo: Redirect to [carID] screen
        }
      } else {
        router.push("/");
      }
    } catch (error) {
      console.error("Error creating car:", error);
      router.push("/");
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

Important! Why do we use id and not documentId?

In Strapi 5 there’s a migration between documentId and id, but for Media/Upload in some scenarios relationship linking can be unstable. Because of that, it’s simpler (and in practice more reliable) to use the old id.

This is one of those cases where you need to know the platform’s quirks and not argue with reality — the main thing is that the relation is set correctly.

UpdateCar mutation: link the image to the created Car

Now that the file is uploaded and we have its id, we need to update the Car and set its image.

Create app/graphql/UpdateCar.mutation.gql:

mutation UpdateCar($documentId: ID!, $data: CarInput!) {
  updateCar(documentId: $documentId, data: $data) {
    documentId
  }
}
Enter fullscreen mode Exit fullscreen mode

Don’t forget to run:

pnpm run codegen
Enter fullscreen mode Exit fullscreen mode

API route for update

Create app/api/update-car/route.ts and put the following logic inside:

import { getClient } from "../../apollo-client";
import { UpdateCarDocument } from "@/generated/graphql";

export async function POST(request: Request) {
  try {
    const body = await request.json();

    const client = getClient();

    const result = await client.mutate({
      mutation: UpdateCarDocument,
      variables: {
        data: { image: body.imageDocId },
        documentId: body.documentId,
      },
    });

    return Response.json({ success: true, data: result.data });
  } catch (error) {
    console.error("Error updating car:", error);
    return Response.json(
      { success: false, error: (error as Error).message },
      { status: 500 },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode
  • documentId — the Car identifier created by the first mutation
  • imageDocId — the actual id of the uploaded file
  • update via data: { image: body.imageDocId }

Final handleSubmit: create → upload → update → redirect

Here’s the complete method that performs the entire flow:

const handleSubmit = (e: React.SubmitEvent<HTMLFormElement>) => {
  e.preventDefault();

  startTransition(async () => {
    try {
      const response = await fetch("/api/car", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          name: form.name,
          model: form.model,
          year: form.year,
          owners: form.owners,
        }),
      });

      const json = await response.json();
      const data: CreateCarMutation = json.data;

      if (data?.createCar?.documentId) {
        const carDocumentId = data.createCar.documentId;

        const formData = new FormData();
        if (form.image) {
          formData.append("files", form.image);
        }

        const fileUploadResponse = await fetch(
          "http://localhost:1337/api/upload",
          {
            method: "POST",
            body: formData,
          },
        );

        const fileUpload = await fileUploadResponse.json();
        const fileDocId = fileUpload[0]?.id;

        if (fileDocId) {
          const updateCarResponse = await fetch("/api/update-car", {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({
              documentId: carDocumentId,
              imageDocId: fileDocId,
            }),
          });

          const updateCarData: {
            data: unknown;
            success: boolean;
          } = await updateCarResponse.json();

          if (updateCarData?.success) {
            router.push(`/cars/${carDocumentId}`);
            return;
          }
        }

        router.push("/");
      } else {
        router.push("/");
      }
    } catch (error) {
      console.error("Error creating car:", error);
      router.push("/");
    }
  });
};
Enter fullscreen mode Exit fullscreen mode

What happens here (step by step):

  1. CreateCar mutation creates the car document (via the /api/car route)
  2. Upload the image (Strapi REST /api/upload)
  3. Update the car with UpdateCar to link the uploaded file (GraphQL mutation via /api/update-car)

If everything succeeds — redirect to the new car’s page.

About edge cases (and why that’s OK)

Yes, we intentionally didn’t cover every “what if?” case:

  • the car was created, but upload failed
  • upload succeeded, but update failed
  • the user didn’t select a file
  • the backend returned an unexpected response shape

In a real product, you’d solve this with:

  • transactional logic (or background jobs)
  • retry/rollback strategies
  • proper user-facing error messages
  • input validation and an API response schema

For a demo, the key takeaway is: codegen types mutations just as well as queries, and “one server-side Apollo client” lets you keep client components simple.

Conclusion: types as a “bonus” that works for you

In this demo, we built a simple management module for Car and Owner using Next.js (App Router) + Apollo GraphQL + Strapi, without turning the project into an “interface factory”.

The most valuable part of this architecture is that you almost never write types by hand. GraphQL Code Generator takes the schema from the backend, reads your .gql documents, and generates:

  • typed DocumentNodes for queries/mutations (CarsDocument, CarDocument, CreateCarDocument, UpdateCarDocument)
  • response types (CarsQuery, CarQuery, CreateCarMutation, UpdateCarMutation)
  • variables types (CarQueryVariables, CreateCarMutationVariables, etc.)

As a result, types live in one place — schema + gql documents become the single source of truth. This removes the classic pain points:

  • duplicated models between frontend and backend
  • “types exist, but they don’t match reality anymore”
  • naming collisions and endless interface edits after every API change

It’s also worth calling out the practical mutation setup. We didn’t bind ourselves to useQuery / useMutation, and we didn’t build a separate client-side infrastructure. Instead we:

  • kept one Apollo Client on the server
  • used fetch from client components to Next.js API routes
  • mixed GraphQL (create/update) and REST (upload) — which happens constantly in real projects

Yes, at the fetch boundary you still type the JSON response explicitly — but that’s a small price for a transparent architecture and minimal “magic” in the UI.

In short: codegen is one of those rare tools that truly delivers “less code — fewer problems” without compromising quality. Run generation — and you get types that don’t lie.

Top comments (0)