If you're a fullstack TypeScript developer in 2026, your IDE is probably already talking back to you. But there's a difference between autocomplete and a prompt that actually understands your stack. The right prompt can generate a type-safe API layer, scaffold a Next.js App Router page with server components, or catch a subtle React hydration bug before it ships. The wrong prompt gives you any types and a headache.
This cheat sheet collects 14 battle-tested prompts for real fullstack work. Each one targets a specific pain point: API typing, component generation, App Router setup, build optimization, deployment. You'll get the exact prompt text, a short explanation of why it works, and a concrete example of what the AI should return. No fluff — just the prompts I keep in a scratchpad and reuse weekly.
A quick note on tooling: these prompts work with any LLM that has access to your project context (Claude, GPT-4o, Cursor, GitHub Copilot Chat). The quality of output depends heavily on how much context you feed. If your tool supports project indexing, enable it. If it doesn't, paste the relevant files or types into the prompt. The prompts below assume you can reference file paths and existing types.
How to Use These Prompts
Before diving in, three rules that separate a useful LLM response from garbage:
- Always include your
tsconfig.jsonsettings. Strict mode,noUncheckedIndexedAccess,exactOptionalPropertyTypes— these change the output. If the AI doesn't know you havestrict: true, it will generate nullable types that don't match your codebase. - Reference real types. If you have a
Userinterface insrc/types/user.ts, say so. The AI will fetch it (if indexing is on) or you should paste it. Prompts that say "type this API response" without context produce generic shapes. - Ask for tests. A prompt that generates code without tests is half a prompt. TypeScript's type system catches a lot, but runtime behavior (especially in React and Node.js streams) needs assertions. Add "include a Vitest test" to almost any prompt.
The prompts below are grouped by scenario: API typing, React components, Next.js App Router, Node.js backend, build and deploy. Each has a Task, Prompt, and Example output section.
1. Typing an External REST API Response (OpenAPI or Raw JSON)
Task: You're integrating a third-party API (Stripe, GitHub, an internal service) and need TypeScript types that match the actual response, not a hand-written guess.
Prompt:
You are a TypeScript expert. I have a REST API endpoint that returns JSON. Here is a sample response:
[paste 1-2 real JSON objects, redact sensitive data]
Generate a TypeScript interface for this response. Requirements:
- Use `type` not `interface` for consistency with my codebase (I use `type` everywhere).
- Mark fields that can be null as `| null`, not optional, unless the API omits them entirely.
- For arrays, infer the element type from the sample. If the array is empty in the sample, type it as `unknown[]` and add a comment.
- Add JSDoc comments only for fields whose meaning is non-obvious (e.g., `status` codes).
- Do not use `any`. If a field's type is ambiguous, use `unknown` and explain why.
- Output only the type definition, no explanation.
Why it works: The "sample response" anchor forces the AI to ground its output in reality. The instruction to use unknown over any is critical — any disables type checking, unknown forces you to narrow. The JSDoc constraint prevents the AI from adding noise.
Example output:
type GitHubRepo = {
id: number;
name: string;
full_name: string;
private: boolean;
owner: {
login: string;
id: number;
avatar_url: string;
};
description: string | null;
fork: boolean;
created_at: string; // ISO 8601
updated_at: string;
stargazers_count: number;
language: string | null;
};
Pro tip: Run the output through tsc --noEmit and then validate against a real response using Zod or tsd (the type testing library). If you want runtime validation, ask the AI to generate a Zod schema instead of a plain type — it will produce both the schema and the inferred type.
2. Generating a Type-Safe API Client with Fetch and Zod
Task: You have types, now you need a client that validates responses at runtime and throws typed errors.
Prompt:
Write a type-safe API client in TypeScript for the endpoint GET /users/:id. Use `fetch` (no axios). Requirements:
- Define a Zod schema `UserSchema` for the response.
- Infer the TypeScript type from the schema: `type User = z.infer<typeof UserSchema>`.
- The client function `getUser(id: string): Promise<User>` should:
- Throw a custom `ApiError` class if the response is not ok, including status code and body text.
- Parse the response with `UserSchema.parse` and throw a `ZodError` if validation fails.
- Include a Vitest test that mocks `fetch` and asserts both success and failure paths.
- Use `unknown` for the raw JSON before parsing.
Why it works: Zod gives you runtime safety and type inference in one shot. The custom error class makes error handling explicit. The test requirement forces the AI to think about edge cases.
Example output (abridged):
import { z } from 'zod';
export const UserSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().datetime(),
});
export type User = z.infer<typeof UserSchema>;
export class ApiError extends Error {
constructor(public status: number, public body: string) {
super(`API error ${status}: ${body}`);
}
}
export async function getUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
const json: unknown = await res.json();
return UserSchema.parse(json);
}
Real-world note: This pattern saved a team I worked with from a production incident when a third-party API started returning null for a field that was previously always a string. Zod caught it at the boundary, and the error was logged with the exact field path.
3. Converting a React Class Component to a Function Component with Hooks
Task: Legacy codebase migration. You have a class component with lifecycle methods and need a modern function component, preserving behavior.
Prompt:
Convert this React class component to a function component using hooks. Preserve all behavior exactly.
[paste the class component]
Rules:
- Replace `componentDidMount` and `componentDidUpdate` with `useEffect`. Be explicit about dependency arrays — do not lie about dependencies to silence the linter.
- Replace instance properties used in render with `useRef` if they don't trigger re-renders, or `useState` if they do.
- If the component uses `this.setState` with a callback, convert to `useEffect` with the state as a dependency.
- Keep the same prop types. If it uses `PropTypes`, convert to a TypeScript interface.
- Add a comment above each `useEffect` explaining what it replaces.
Why it works: The instruction to not lie about dependencies is key — the most common bug in class-to-hook migrations is an incomplete dependency array that causes stale closures. The comment requirement makes the diff reviewable.
Example: A componentDidMount that fetched data becomes:
// Replaces componentDidMount: fetch user on mount
useEffect(() => {
let cancelled = false;
fetchUser(userId).then((u) => {
if (!cancelled) setUser(u);
});
return () => { cancelled = true; };
}, [userId]);
Gotcha: If the original class used componentWillUnmount to cancel a subscription, the AI should return a cleanup function from useEffect. Always check that the cleanup exists.
4. Scaffolding a Next.js App Router Page with Server Components
Task: You need a new route in Next.js 14+ (App Router) that fetches data on the server, renders a client component for interactivity, and handles loading and error states.
Prompt:
Create a Next.js App Router page at `app/dashboard/page.tsx`. Requirements:
- The page is a Server Component (default). It fetches data from `getDashboardData()` (assume this function exists and returns `Promise<DashboardData>`).
- Pass the data to a Client Component `<DashboardClient data={data} />` defined in `app/dashboard/dashboard-client.tsx`.
- Add `app/dashboard/loading.tsx` with a skeleton UI.
- Add `app/dashboard/error.tsx` as a Client Component that accepts `{ error, reset }` props and shows a retry button.
- Use `export const dynamic = 'force-dynamic'` only if necessary — explain why or why not.
- Include the TypeScript types for `DashboardData` in a separate `types.ts`.
- Do not use `use client` in the page file.
Why it works: The explicit split between server and client components prevents the most common App Router mistake: adding use client to a page that fetches data. The loading and error file requirements match Next.js conventions. The dynamic instruction forces the AI to justify caching behavior.
Example output (page):
// app/dashboard/page.tsx
import { DashboardClient } from './dashboard-client';
import { getDashboardData } from '@/lib/data';
export default async function DashboardPage() {
const data = await getDashboardData();
return <DashboardClient data={data} />;
}
Note: In Next.js 15, cookies() and headers() became async. If you're on 15+, add "use Next.js 15 async request APIs" to the prompt. The AI will then generate const cookieStore = await cookies() instead of the synchronous version.
5. Writing a Node.js Stream Pipeline with Backpressure Handling
Task: You're processing a large file (CSV, log) and need a memory-safe stream pipeline in TypeScript.
Prompt:
Write a Node.js TypeScript script that reads a large CSV file, transforms each row, and writes to a new file. Requirements:
- Use `node:stream` and `node:readline` for line-by-line processing.
- Handle backpressure: use `pipeline` from `node:stream/promises`.
- Transform function: parse each line, skip malformed rows, log a warning with line number.
- Type the transform function with generics.
- Include a test using a small in-memory stream (no file I/O in tests).
- Target Node.js 20+.
Why it works: Backpressure is the #1 source of memory leaks in Node.js streams. Using pipeline from stream/promises automatically handles cleanup and error propagation. The test requirement with in-memory streams makes the code testable without fixtures.
Example output (core):
import { createReadStream, createWriteStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
async function processCsv(input: string, output: string) {
const readStream = createReadStream(input, { encoding: 'utf8' });
const rl = createInterface({ input: readStream, crlfDelay: Infinity });
const writeStream = createWriteStream(output);
let lineNumber = 0;
const transform = new Transform({
objectMode: true,
transform(line: string, _enc, cb) {
lineNumber++;
const parsed = parseRow(line);
if (!parsed) {
console.warn(`Skipping malformed line ${lineNumber}`);
return cb();
}
cb(null, JSON.stringify(parsed) + '\n');
},
});
await pipeline(rl, transform, writeStream);
}
Real-world note: A backend team I know reduced memory usage from 2 GB to under 100 MB by switching from fs.readFile to this pipeline pattern on a 5 GB log file.
6. Optimizing a Slow tsc Build with Project References
Task: Your monorepo's tsc build takes minutes. You want to split it into project references and enable incremental builds.
Prompt:
I have a TypeScript monorepo with packages: `shared`, `api`, `web`. The root `tsconfig.json` compiles everything, and `tsc` takes over 2 minutes.
Help me set up TypeScript project references. Requirements:
- Create a `tsconfig.json` in each package with `composite: true` and `references` to dependencies (`api` and `web` reference `shared`).
- Set `incremental: true` and specify `tsBuildInfoFile` paths.
- Show the root `tsconfig.json` with `references` only, no `include`.
- Explain how to run `tsc --build` and what `--watch` does in this setup.
- Warn me about common pitfalls: circular references, missing `composite`, and `declaration` output.
Why it works: Project references are the official TypeScript solution for monorepos, but the setup is error-prone. The prompt asks for pitfalls, which surfaces issues like composite requiring declaration: true. The --build mode caches results, so incremental builds are fast.
Example output (root):
{
"files": [],
"references": [
{ "path": "./packages/shared" },
{ "path": "./packages/api" },
{ "path": "./packages/web" }
]
}
Source: The TypeScript handbook's "Project References" page (typescriptlang.org/docs/handbook/project-references.html) is the authoritative guide. Cite it in your PR description.
7. Generating a React Hook for Debounced Search with AbortController
Task: You need a search input that debounces API calls and cancels in-flight requests when the query changes.
Prompt:
Write a custom React hook `useDebouncedSearch<T>(query: string, delayMs: number): { results: T[]; loading: boolean; error: Error | null }`. Requirements:
- Debounce the query by `delayMs`.
- Use `AbortController` to cancel the previous fetch when a new one starts or the component unmounts.
- Handle race conditions: only the latest request should update state.
- Type the fetch function as a parameter or use a generic `searchFn: (q: string, signal: AbortSignal) => Promise<T[]>`.
- Include a test with `@testing-library/react` and fake timers.
- No external state management libraries.
Why it works: Race conditions in search are a classic bug. AbortController plus a check on signal.aborted prevents stale updates. The generic searchFn parameter makes the hook reusable. The test with fake timers ensures the debounce actually works.
Example output (core):
import { useEffect, useState } from 'react';
export function useDebouncedSearch<T>(
query: string,
delayMs: number,
searchFn: (q: string, signal: AbortSignal) => Promise<T[]>
) {
const [results, setResults] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!query) { setResults([]); return; }
const controller = new AbortController();
const timer = setTimeout(async () => {
setLoading(true);
try {
const data = await searchFn(query, controller.signal);
if (!controller.signal.aborted) setResults(data);
} catch (e) {
if (!controller.signal.aborted) setError(e as Error);
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, delayMs);
return () => { clearTimeout(timer); controller.abort(); };
}, [query, delayMs, searchFn]);
return { results, loading, error };
}
8. Writing a Type-Safe Express Middleware for JWT Auth
Task: You need Express middleware that validates a JWT and augments the Request type with the decoded user.
Prompt:
Write Express middleware in TypeScript for JWT authentication. Requirements:
- Use `jsonwebtoken` and `@types/jsonwebtoken`.
- Augment the Express `Request` type using declaration merging so `req.user` is typed as `{ userId: string; role: 'admin' | 'user' }`.
- Middleware function `requireAuth` returns 401 if no token or invalid token, 403 if role doesn't match.
- Export a `requireRole(role: string)` factory for role-based access.
- Show how to apply it to a router: `router.get('/admin', requireAuth, requireRole('admin'), handler)`.
- Include a test with `supertest`.
Why it works: Declaration merging is the correct way to extend Express types. Without it, you end up casting req as any, which defeats TypeScript. The factory pattern for roles keeps the middleware composable.
Example output (types):
import { Request } from 'express';
declare global {
namespace Express {
interface Request {
user?: { userId: string; role: 'admin' | 'user' };
}
}
}
Real-world note: This pattern is used in production by many Node.js APIs. The key is to load the augmentation in a .d.ts file or at the top of your entry point so it's applied globally.
9. Creating a Zod Schema from a Prisma Model
Task: You use Prisma and want Zod schemas that match your models for request validation.
Prompt:
I have this Prisma model:
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
createdAt DateTime @default(now())
}
enum Role { USER ADMIN }
Generate Zod schemas for:
1. `UserCreateSchema` — for POST /users. Omit `id` and `createdAt`, make `name` optional, validate email.
2. `UserUpdateSchema` — for PATCH /users/:id. All fields optional except `id`.
3. `UserResponseSchema` — full model for API responses, with `createdAt` as ISO string.
Use `z.infer` to export TypeScript types. Add a `.refine` to `UserCreateSchema` that rejects emails from a disposable domain list (use a placeholder array).
Why it works: Prisma types are for the database, Zod types are for the API. Keeping them in sync manually is error-prone. This prompt generates the bridge. The .refine example shows how to add business logic.
Example output:
import { z } from 'zod';
export const UserCreateSchema = z.object({
email: z.string().email(),
name: z.string().min(1).optional(),
role: z.enum(['USER', 'ADMIN']).default('USER'),
}).refine((data) => !DISPOSABLE_DOMAINS.includes(data.email.split('@')[1]), {
message: 'Disposable email domains are not allowed',
path: ['email'],
});
export type UserCreate = z.infer<typeof UserCreateSchema>;
10. Debugging a Hydration Mismatch in Next.js
Task: Your Next.js app throws "Hydration failed because the initial UI does not match what was rendered on the server." You need to find and fix the cause.
Prompt:
My Next.js app has a hydration mismatch error. Here is the component that likely causes it:
[paste component]
Analyze the code and identify all possible causes of hydration mismatch. Consider:
- Use of `Date.now()`, `Math.random()`, or `new Date()` in render.
- Browser-only APIs (`window`, `localStorage`) accessed during render.
- Conditional rendering based on `typeof window`.
- Invalid HTML nesting (e.g., `<div>` inside `<p>`).
- Third-party libraries that render differently on server and client.
For each cause, show the fix. If the fix requires `useEffect` or `dynamic import with ssr: false`, show the correct pattern. Do not suggest suppressing the error with `suppressHydrationWarning` unless it's a last resort — explain why.
Why it works: Hydration mismatches have a finite set of causes. The prompt enumerates them, so the AI doesn't guess. The instruction against suppressHydrationWarning prevents a band-aid fix.
Example fix:
// Before: causes mismatch
const time = new Date().toLocaleTimeString();
// After: render only on client
const [time, setTime] = useState<string | null>(null);
useEffect(() => { setTime(new Date().toLocaleTimeString()); }, []);
if (!time) return <Skeleton />;
return <span>{time}</span>;
Source: Next.js docs on hydration errors (nextjs.org/docs/messages/react-hydration-error) list the common causes. The React docs on useSyncExternalStore are relevant for external data.
11. Optimizing a Next.js Bundle with Dynamic Imports
Task: Your Next.js bundle is too large. You need to code-split heavy components and analyze the bundle.
Prompt:
My Next.js app has a large client bundle. Here is my `next.config.js` and a list of heavy dependencies:
[paste config and dependencies]
Suggest optimizations:
1. Which components should use `next/dynamic` with `ssr: false`? Show the import syntax.
2. How to configure `@next/bundle-analyzer`.
3. Which dependencies can be replaced with lighter alternatives (e.g., `lodash` → `lodash-es` or native methods, `moment` → `date-fns`).
4. How to use `optimizePackageImports` in `next.config.js` for icon libraries.
5. Any server-only dependencies that should not be in the client bundle.
For each suggestion, explain the expected impact. Do not guess bundle sizes — say "measure with analyzer" instead.
Why it works: The instruction to measure with the analyzer prevents the AI from inventing numbers. The optimizePackageImports config is a real Next.js feature (available since 13.5) that tree-shakes icon libraries like lucide-react.
Example output:
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
experimental: {
optimizePackageImports: ['lucide-react', '@heroicons/react'],
},
});
// Dynamic import for a heavy chart component
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('@/components/Chart'), {
ssr: false,
loading: () => <ChartSkeleton />,
});
Real-world note: A dashboard team reduced their First Load JS from 450 KB to 210 KB by moving chart libraries to dynamic imports and replacing moment with date-fns. Always verify with ANALYZE=true next build.
12. Writing a GitHub Actions Workflow for TypeScript CI
Task: You need a CI pipeline that runs type checking, tests, and linting on every PR.
Prompt:
Write a GitHub Actions workflow `.github/workflows/ci.yml` for a TypeScript monorepo. Requirements:
- Trigger on pull_request and push to main.
- Use Node.js 20 and pnpm (with caching).
- Steps: install dependencies, run `tsc --noEmit`, run ESLint, run Vitest with coverage.
- Upload coverage to Codecov only on the main branch.
- Fail fast: cancel in-progress runs for the same PR.
- Use `actions/checkout@v4` and `pnpm/action-setup@v4`.
- Do not use deprecated actions.
Why it works: The fail-fast concurrency setting saves CI minutes. The pnpm cache speeds up installs. The condition on Codecov upload prevents noise from PRs. Using specific action versions avoids deprecation warnings.
Example output:
name: CI
on:
pull_request:
push:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm tsc --noEmit
- run: pnpm lint
- run: pnpm test -- --coverage
- uses: codecov/codecov-action@v4
if: github.ref == 'refs/heads/main'
Gotcha: pnpm/action-setup@v4 requires the version input if you don't have a packageManager field in package.json. The AI might miss this — check the output.
13. Generating a Dockerfile for a Next.js Standalone Build
Task: You want a small, production-ready Docker image for a Next.js app using the standalone output mode.
Prompt:
Write a multi-stage Dockerfile for a Next.js 15 app with TypeScript. Requirements:
- Use `output: 'standalone'` in next.config.js.
- Stage 1: install dependencies with pnpm.
- Stage 2: build the app.
- Stage 3: runtime image based on `node:20-alpine`, copy only `.next/standalone`, `.next/static`, and `public`.
- Run as non-root user.
- Set `NODE_ENV=production`.
- Expose port 3000.
- Include a `.dockerignore` file.
- Explain why standalone mode reduces image size.
Why it works: Standalone mode traces only the dependencies your app actually uses, which can cut image size significantly. The non-root user is a security best practice. The multi-stage build keeps the final image free of build tools.
Example output (runtime stage):
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Source: Next.js docs on standalone output (nextjs.org/docs/app/api-reference/config/next-config-js/output) describe the exact files to copy.
14. Setting Up TypeScript Path Aliases with tsconfig and Vitest
Task: You want to use @/ imports in a Vite + React + TypeScript project and have Vitest resolve them too.
Prompt:
Configure TypeScript path aliases for a Vite + React + TypeScript project. Requirements:
- Set `baseUrl` and `paths` in `tsconfig.json` so `@/components/Button` resolves to `src/components/Button`.
- Configure Vite to respect the aliases using `vite-tsconfig-paths` plugin.
- Configure Vitest to resolve them (either via the same plugin or `resolve.alias`).
- Show the exact `tsconfig.json`, `vite.config.ts`, and `vitest.config.ts` snippets.
- Explain why `moduleResolution: 'bundler'` is recommended for Vite projects.
Why it works: Path aliases are a frequent source of "module not found" errors because four tools (tsc, Vite, Vitest, ESLint) each need configuration. The moduleResolution: 'bundler' setting is the modern recommendation for Vite and matches how Vite resolves modules.
Example output:
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"moduleResolution": "bundler"
}
}
// vite.config.ts
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
});
Real-world note: If ESLint reports unresolved imports, add eslint-import-resolver-typescript and configure it to read tsconfig.json. This is the missing piece in most setups.
These 14 prompts cover the full loop: type your data, build your UI, wire your server, optimize your build, ship your container. The common thread is specificity — the more context you give (your tsconfig, your existing types, your real API responses), the less editing you'll do afterward. Treat prompts like code reviews: if the output uses any, pushes back. If it invents a dependency, verify it exists on npm first.
If you want to go deeper into TypeScript, Next.js, and AI-assisted development, asibiont.com has text-based courses that walk through these patterns with hands-on exercises. No video, no fluff — just code you can run. Pick one prompt from this list, try it on your current project, and see how much time you save. Then bookmark this page for the next time you're staring at a blank page.tsx.
Comments