Skip to content
swaggerz

05Project

Next.js example

A Next.js App Router site whose server components render a task list and one page per task, through a client generated from an OpenAPI 3.1 document (openapi.yaml). withSwaggerz() from swaggerz/next generates the client: there is no separate generate step.

Terminal window
pnpm example:next # from the repository root: build swaggerz, start next dev

Or, inside this directory once swaggerz is built:

Terminal window
pnpm dev # http://localhost:3000: generates src/generated/api, regenerates when openapi.yaml changes
pnpm check # builds the site (generating the client first, then type-checking it)
pnpm dashboard # explore the API and the generated code

What to look at:

  • next.config.ts wraps the config with withSwaggerz(). next dev generates the client when it starts and again whenever openapi.yaml or swaggerz.config.ts changes (problems are printed in the terminal); next build generates it first and fails when it cannot. See framework integrations.
  • swaggerz.config.ts sets output.importExtension: 'none': neither Turbopack nor webpack in Next.js resolves the default ./client.js imports to client.ts files. swaggerz init sets it in projects that depend on next.
  • src/app/page.tsx lists the tasks with listTasks; src/app/tasks/[id]/page.tsx prerenders one page per task with generateStaticParams and getTask, and turns a 404 into notFound() with the operation’s error guard, which types the body by status.
  • src/api.ts configures the client once. Its requests are answered in process by the generated mock handlers through mockFetch() (src/mocks.ts): no server and no network, at build time and in next dev. To call the real API, drop the transport and set baseUrl. See mocks.

Config

swaggerz.config.ts
import { defineConfig } from 'swaggerz';
export default defineConfig({
spec: { version: '3.1', input: './openapi.yaml' },
// Next.js (Turbopack and webpack) does not resolve `./client.js` to `client.ts`: generate
// imports without an extension.
output: { directory: './src/generated/api', importExtension: 'none' },
// Mock Service Worker handlers and fixtures: src/api.ts answers the client's requests with them.
features: { mocks: 'msw' },
});

Sources

next.config.ts
import type { NextConfig } from 'next';
import { withSwaggerz } from 'swaggerz/next';
const nextConfig: NextConfig = {
reactStrictMode: true,
};
// Generates src/generated/api from openapi.yaml: when `next dev` starts and again whenever the
// specification or swaggerz.config.ts changes, and before `next build`.
export default withSwaggerz(nextConfig);
src/api.ts
/**
* The generated client, configured once for the server components. Its requests are answered in
* process by the generated mocks (src/mocks.ts): no server, no network. To call the real API,
* drop the transport and set `baseUrl` to the API's URL.
*/
import { client, createFetchTransport } from './generated/api';
import { mockFetch } from './generated/api/mocks';
import { handlers } from './mocks';
client.setConfig({
baseUrl: 'https://tasks.example.com/api',
transport: createFetchTransport({ fetch: mockFetch(handlers) }),
});
export { getTask, isGetTaskApiError, listTasks } from './generated/api';
src/app/layout.tsx
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import './styles.css';
export const metadata: Metadata = { title: 'Tasks' };
export default function RootLayout({ children }: { readonly children: ReactNode }) {
return (
<html lang="en">
<body>
<main>{children}</main>
</body>
</html>
);
}
src/app/page.tsx
import Link from 'next/link';
import { listTasks } from '../api';
// A server component: rendered at build time, through the generated client.
export default async function Tasks() {
const { items } = await listTasks({ limit: 50 });
return (
<>
<h1>Tasks</h1>
<ul>
{items.map((task) => (
<li key={task.id}>
<Link href={`/tasks/${task.id}`}>{task.title}</Link>{' '}
<span className="status">{task.status}</span>
</li>
))}
</ul>
</>
);
}
src/app/tasks/[id]/page.tsx
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getTask, isGetTaskApiError, listTasks } from '../../../api';
// One page per task, listed through the generated client at build time.
export async function generateStaticParams() {
const { items } = await listTasks({ limit: 50 });
return items.map((task) => ({ id: task.id }));
}
async function load(id: string) {
try {
return await getTask({ taskId: id });
} catch (error) {
// Typed by status: a 404 from getTask has a Problem body.
if (isGetTaskApiError(error) && error.status === 404) notFound();
throw error;
}
}
export default async function Task({ params }: { readonly params: Promise<{ id: string }> }) {
const task = await load((await params).id);
return (
<>
<p>
<Link href="/">All tasks</Link>
</p>
<h1>{task.title}</h1>
<p className="status">{task.status}</p>
{task.notes !== null && task.notes !== undefined && <p>{task.notes}</p>}
<p>Created {new Date(task.createdAt).toLocaleDateString('en', { dateStyle: 'long' })}</p>
</>
);
}
src/mocks.ts
/**
* The Tasks API, answered in this process by the generated Mock Service Worker handlers over a
* fixed list of tasks. Typed with the generated models: it stops compiling when the
* specification changes.
*/
import { HttpResponse } from 'msw';
import type { Problem, Task } from './generated/api';
import { makeTask, mockGetTask, mockListTasks } from './generated/api/mocks';
const tasks: Task[] = [
makeTask({ id: '3', title: 'Ship the release', status: 'todo', notes: null }),
makeTask({ id: '2', title: 'Write the changelog', status: 'doing', notes: 'Group by feature' }),
makeTask({ id: '1', title: 'Generate the client', status: 'done', notes: null }),
];
export const handlers = [
mockListTasks({
body: ({ query }) => {
const status = query.get('status');
return { items: tasks.filter((task) => status === null || task.status === status) };
},
}),
mockGetTask({
body: ({ params }) =>
tasks.find((task) => task.id === params.taskId) ??
HttpResponse.json({ title: 'Not found' } satisfies Problem, { status: 404 }),
}),
];

Generated files

36 files for 5 operations and 6 models, generated with this config when the site was built:

  • client.ts, index.ts
  • mocks/16 files
  • models/6 files
  • runtime/7 files
  • tasks/5 files

The project on GitHub