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
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
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
During installation:
- choose the default settings
- skip Strapi Cloud — we don’t need it for this example
Start the server:
pnpm run dev
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
After installation, open:
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
In the User model (which we’ll treat as Owner), add a field:
fullname — text
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
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
}
}
}
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
.gqldocuments - 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;
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"
}
}
Run the new command:
pnpm run codegen
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", ... }],
};
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>
);
}
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>
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
}
}
}
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
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>
);
}
What we got thanks to codegen
In this example there isn’t a single handwritten TypeScript type for:
- the server response
- the
carstructure - the query variables
TypeScript infers types from:
CarDocument → CarQuery → Apollo query result
Practical impact:
- autocomplete for all fields
- compile-time variable validation
- no duplicated interfaces
- single source of truth — GraphQL schema +
.gqldocuments - 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/useMutationand 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
}
}
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
MediatoCarvia 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
}
}
Generate types
After adding new documents, run codegen:
pnpm run codegen
Now generated/graphql.ts will include:
OwnersDocument, OwnersQuery
CreateCarDocument, CreateCarMutation
CreateCarMutationVariables
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>
);
}
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>
);
}
Where is the “typing” here? This line is very telling:
ownersData: OwnersQuery["usersPermissionsUsers"];
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 },
);
}
}
A few notes on the code:
-
request.json()reads the payload from the form. -
variables: { data: body }— the mutation expects$data: CarInput!. -
result.datais typed in the route file, but that type information is lost after afetchcall.
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);
}
});
};
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;
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
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("/");
}
});
};
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
}
}
Don’t forget to run:
pnpm run codegen
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 },
);
}
}
-
documentId— the Car identifier created by the first mutation -
imageDocId— the actualidof 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("/");
}
});
};
What happens here (step by step):
-
CreateCarmutation creates the car document (via the/api/carroute) - Upload the image (Strapi REST
/api/upload) - Update the car with
UpdateCarto 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
fetchfrom 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)