12 Prompts for Creating React/Next.js Applications

Why Prompt Engineering Matters for React and Next.js Developers

As a developer who works daily with React and Next.js, you've likely noticed the shift in how we write code. AI tools like GitHub Copilot, Claude, and ChatGPT have become indispensable, but their output quality depends heavily on how you phrase your requests. A well-crafted prompt can save hours of boilerplate, generate production-ready components, and even help you debug complex state issues.

This collection compiles 12 battle-tested prompts I use in my own workflow. Each prompt is complete with a real usage example and the resulting code. These aren't theoretical — they're prompts that have helped me ship features faster, write cleaner components, and avoid common React pitfalls.

Whether you're building a new Next.js page, refactoring a legacy class component, or optimizing performance, this list will give you a starting point for more effective AI-assisted development.

1. Generate a TypeScript React Component with Props Interface

Prompt:

Create a TypeScript React component for a user profile card. It should accept props for name, email, avatar URL, and optional bio. Include types via an interface, default props, and a fallback UI when the avatar fails to load.

Usage example:

interface UserProfileCardProps {
  name: string;
  email: string;
  avatarUrl: string;
  bio?: string;
}

const UserProfileCard: React.FC<UserProfileCardProps> = ({
  name,
  email,
  avatarUrl,
  bio = 'No bio available',
}) => {
  const [imgError, setImgError] = useState(false);

  return (
    <div className="flex items-center gap-4 p-4 border rounded-lg shadow-sm">
      {imgError ? (
        <div className="w-16 h-16 bg-gray-200 rounded-full flex items-center justify-center text-gray-500">
          {name.charAt(0)}
        </div>
      ) : (
        <img
          src={avatarUrl}
          alt={name}
          onError={() => setImgError(true)}
          className="w-16 h-16 rounded-full object-cover"
        />
      )}
      <div>
        <h3 className="text-lg font-semibold">{name}</h3>
        <p className="text-sm text-gray-600">{email}</p>
        {bio && <p className="mt-1 text-sm">{bio}</p>}
      </div>
    </div>
  );
};

Why it works: Including specific requirements (fallback UI, default props) forces the AI to write defensive code. Without these, generated components often lack error handling.

2. Create a Next.js App Router Page with Server Components and Data Fetching

Prompt:

Write a Next.js 14 page using the App Router. It should fetch data from https://api.example.com/posts using server-side fetch, display a list of posts with title and excerpt, handle loading states with Suspense, and show an error boundary.

Usage example:

// app/posts/page.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';

async function PostsList() {
  const res = await fetch('https://api.example.com/posts', {
    cache: 'force-cache',
  });
  if (!res.ok) throw new Error('Failed to fetch posts');
  const posts = await res.json();

  return (
    <ul>
      {posts.map((post: { id: number; title: string; excerpt: string }) => (
        <li key={post.id} className="mb-4 p-4 border rounded">
          <h2 className="text-xl font-bold">{post.title}</h2>
          <p className="text-gray-600">{post.excerpt}</p>
        </li>
      ))}
    </ul>
  );
}

export default function PostsPage() {
  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl mb-6">Blog Posts</h1>
      <ErrorBoundary fallback={<p>Something went wrong loading posts.</p>}>
        <Suspense fallback={<p>Loading posts...</p>}>
          <PostsList />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Why it works: Specifying App Router, Suspense, and error boundary ensures the AI generates modern Next.js patterns, not legacy Pages Router code.

3. Refactor a Class Component to a Functional Component with Hooks

Prompt:

Refactor this React class component to a functional component using hooks. The component manages a counter with increment/decrement and logs the count to console on every update.

class Counter extends React.Component {
  state = { count: 0 };
  componentDidUpdate(prevProps, prevState) {
    console.log('Count changed:', this.state.count);
  }
  render() {
    return (
      <div>
        <p>{this.state.count}</p>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>+</button>
        <button onClick={() => this.setState({ count: this.state.count - 1 })}>-</button>
      </div>
    );
  }
}

Usage example:

import React, { useState, useEffect } from 'react';

const Counter: React.FC = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log('Count changed:', count);
  }, [count]);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
    </div>
  );
};

Why it works: Providing the exact class component code lets the AI understand the logic and preserve behavior while converting to hooks.

4. Generate a Custom Hook for Form Validation

Prompt:

Create a custom React hook called useFormValidation that takes a validation schema (object with field names and validation functions) and returns: values, errors, touched, handleChange, handleBlur, and isValid. Include TypeScript generics.

Usage example:

import { useState, useCallback } from 'react';

type ValidationSchema<T> = {
  [K in keyof T]: (value: T[K]) => string | null;
};

export function useFormValidation<T extends Record<string, any>>(initialValues: T, schema: ValidationSchema<T>) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string | null>>>({});
  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});

  const validateField = useCallback((name: keyof T, value: any) => {
    const validator = schema[name];
    if (validator) {
      const error = validator(value);
      setErrors(prev => ({ ...prev, [name]: error }));
    }
  }, [schema]);

  const handleChange = useCallback((name: keyof T, value: any) => {
    setValues(prev => ({ ...prev, [name]: value }));
    if (touched[name]) {
      validateField(name, value);
    }
  }, [touched, validateField]);

  const handleBlur = useCallback((name: keyof T) => {
    setTouched(prev => ({ ...prev, [name]: true }));
    validateField(name, values[name]);
  }, [values, validateField]);

  const isValid = Object.values(errors).every(e => e === null || e === undefined);

  return { values, errors, touched, handleChange, handleBlur, isValid };
}

Why it works: Including generics ensures type safety. The prompt specifies exactly which properties the hook should return, making the output predictable.

5. Create a Next.js API Route with Middleware for Authentication

Prompt:

Write a Next.js API route using the App Router route handlers. It should be a protected endpoint that returns the current user's data. Use a simple JWT verification middleware. Include error handling for missing or invalid tokens.

Usage example:

// app/api/user/route.ts
import { NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET || 'fallback-secret';

function verifyToken(request: Request): { userId: string } | null {
  const authHeader = request.headers.get('authorization');
  if (!authHeader || !authHeader.startsWith('Bearer ')) return null;
  const token = authHeader.split(' ')[1];
  try {
    const decoded = jwt.verify(token, SECRET) as { userId: string };
    return decoded;
  } catch {
    return null;
  }
}

export async function GET(request: Request) {
  const payload = verifyToken(request);
  if (!payload) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }
  // Simulate fetching user from DB
  const user = { id: payload.userId, name: 'John Doe', email: 'john@example.com' };
  return NextResponse.json(user);
}

Why it works: Specifying App Router route handlers and JWT verification ensures the AI uses the correct Next.js 13+ patterns rather than the old pages/api style.

6. Generate a Responsive Navigation Component with Tailwind CSS

Prompt:

Create a responsive navigation bar component for a Next.js app using Tailwind CSS. It should have a logo, links (Home, About, Contact), a mobile hamburger menu that toggles, and the active link should be highlighted. Use the 'use client' directive for interactivity.

Usage example:

'use client';

import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';

const links = [
  { href: '/', label: 'Home' },
  { href: '/about', label: 'About' },
  { href: '/contact', label: 'Contact' },
];

export default function Navbar() {
  const [isOpen, setIsOpen] = useState(false);
  const pathname = usePathname();

  return (
    <nav className="bg-white shadow-md">
      <div className="container mx-auto px-4 py-3 flex justify-between items-center">
        <Link href="/" className="text-2xl font-bold text-blue-600">Logo</Link>
        <button onClick={() => setIsOpen(!isOpen)} className="md:hidden">
          <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            {isOpen ? (
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
            ) : (
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
            )}
          </svg>
        </button>
        <div className={`${isOpen ? 'block' : 'hidden'} md:flex md:space-x-6 absolute md:relative top-16 left-0 w-full md:w-auto bg-white md:bg-transparent shadow-md md:shadow-none p-4 md:p-0`}>
          {links.map(link => (
            <Link
              key={link.href}
              href={link.href}
              className={`block py-2 px-4 rounded ${pathname === link.href ? 'bg-blue-100 text-blue-700' : 'text-gray-700 hover:bg-gray-100'}`}
            >
              {link.label}
            </Link>
          ))}
        </div>
      </div>
    </nav>
  );
}

Why it works: Mentioning use client and Tailwind CSS sets the right context. The prompt also specifies the mobile menu behavior, which is often forgotten in generic navigations.

7. Write a React Context Provider with useReducer

Prompt:

Create a React context and provider for a shopping cart using useReducer. Actions should include: ADD_ITEM, REMOVE_ITEM, CLEAR_CART. The cart state should store items with id, name, price, and quantity. Export a custom hook useCart for consuming the context.

Usage example:

import React, { createContext, useContext, useReducer, ReactNode } from 'react';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'CLEAR_CART' };

interface CartState {
  items: CartItem[];
  total: number;
}

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM': {
      const existing = state.items.find(item => item.id === action.payload.id);
      if (existing) {
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + action.payload.quantity }
              : item
          ),
          total: state.total + action.payload.price * action.payload.quantity,
        };
      }
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price * action.payload.quantity,
      };
    }
    case 'REMOVE_ITEM': {
      const item = state.items.find(i => i.id === action.payload.id);
      if (!item) return state;
      return {
        items: state.items.filter(i => i.id !== action.payload.id),
        total: state.total - item.price * item.quantity,
      };
    }
    case 'CLEAR_CART':
      return { items: [], total: 0 };
    default:
      return state;
  }
}

const CartContext = createContext<{
  state: CartState;
  dispatch: React.Dispatch<CartAction>;
} | null>(null);

export function CartProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 });
  return (
    <CartContext.Provider value={{ state, dispatch }}>
      {children}
    </CartContext.Provider>
  );
}

export function useCart() {
  const context = useContext(CartContext);
  if (!context) throw new Error('useCart must be used within a CartProvider');
  return context;
}

Why it works: Specifying the reducer pattern (useReducer) and exact action types ensures the AI generates a scalable state management solution rather than a simple useState.

8. Generate a Loading Skeleton Component

Prompt:

Create a reusable loading skeleton component for a card layout using Tailwind CSS. It should animate with a pulse effect and accept props for the number of cards and lines per card. Use TypeScript.

Usage example:

interface SkeletonCardProps {
  lines: number;
}

const SkeletonCard: React.FC<SkeletonCardProps> = ({ lines }) => (
  <div className="p-4 border rounded-lg shadow-sm animate-pulse">
    <div className="w-full h-40 bg-gray-200 rounded mb-4" />
    {Array.from({ length: lines }).map((_, i) => (
      <div key={i} className={`h-4 bg-gray-200 rounded mb-2 ${i === 0 ? 'w-3/4' : 'w-full'}`} />
    ))}
  </div>
);

interface SkeletonGridProps {
  count: number;
  lines?: number;
}

const SkeletonGrid: React.FC<SkeletonGridProps> = ({ count, lines = 3 }) => (
  <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
    {Array.from({ length: count }).map((_, i) => (
      <SkeletonCard key={i} lines={lines} />
    ))}
  </div>
);

export default SkeletonGrid;

Why it works: The prompt clearly separates the card and grid components, making the skeleton reusable. Including animate-pulse ensures the Tailwind animation class is applied.

9. Create a Custom Hook for Debounced Search

Prompt:

Write a custom React hook useDebouncedSearch that accepts a search function (async), a delay in ms (default 300), and returns: query, setQuery, results, isLoading, error. The hook should cancel previous requests when a new query is made.

Usage example:

import { useState, useEffect, useRef, useCallback } from 'react';

export function useDebouncedSearch<T>(
  searchFn: (query: string) => Promise<T[]>,
  delay: number = 300
) {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<T[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const timerRef = useRef<NodeJS.Timeout | null>(null);
  const abortRef = useRef<AbortController | null>(null);

  const debouncedSearch = useCallback(async (q: string) => {
    if (abortRef.current) {
      abortRef.current.abort();
    }
    const controller = new AbortController();
    abortRef.current = controller;

    setIsLoading(true);
    setError(null);
    try {
      const data = await searchFn(q);
      if (!controller.signal.aborted) {
        setResults(data);
      }
    } catch (err: any) {
      if (err.name !== 'AbortError') {
        setError(err.message || 'Search failed');
      }
    } finally {
      if (!controller.signal.aborted) {
        setIsLoading(false);
      }
    }
  }, [searchFn]);

  useEffect(() => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    if (query) {
      timerRef.current = setTimeout(() => debouncedSearch(query), delay);
    } else {
      setResults([]);
    }
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, [query, delay, debouncedSearch]);

  return { query, setQuery, results, isLoading, error };
}

Why it works: Including AbortController and cancel previous requests ensures the hook handles race conditions properly — a common pitfall in search implementations.

10. Generate a Next.js Static Site Generation (SSG) Page with ISR

Prompt:

Create a Next.js page that uses Incremental Static Regeneration (ISR) to display a list of products. Fetch data from an API at build time, revalidate every 60 seconds, and show a fallback while the page is being regenerated.

Usage example:

// app/products/page.tsx
interface Product {
  id: number;
  name: string;
  price: number;
}

async function getProducts(): Promise<Product[]> {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 },
  });
  if (!res.ok) throw new Error('Failed to fetch');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div className="container mx-auto p-4">
      <h1 className="text-3xl mb-6">Products</h1>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        {products.map(product => (
          <div key={product.id} className="p-4 border rounded shadow">
            <h2 className="text-xl font-semibold">{product.name}</h2>
            <p className="text-gray-600">${product.price.toFixed(2)}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Why it works: The prompt explicitly asks for ISR and revalidate, which are the correct terms for Next.js 13+ App Router. Using next: { revalidate: 60 } is the modern syntax.

11. Write a Unit Test for a React Component with Jest and Testing Library

Prompt:

Write a unit test for a LoginForm component that has email and password inputs and a submit button. Test that: the form renders correctly, validation errors appear when fields are empty, and the onSubmit function is called with the correct data when valid.

Usage example:

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

const mockOnSubmit = jest.fn();

describe('LoginForm', () => {
  beforeEach(() => {
    render(<LoginForm onSubmit={mockOnSubmit} />);
  });

  it('renders email and password fields', () => {
    expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
  });

  it('shows validation errors when fields are empty', async () => {
    fireEvent.click(screen.getByRole('button', { name: /submit/i }));
    expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
    expect(await screen.findByText(/password is required/i)).toBeInTheDocument();
  });

  it('calls onSubmit with form data when valid', async () => {
    await userEvent.type(screen.getByLabelText(/email/i), 'test@example.com');
    await userEvent.type(screen.getByLabelText(/password/i), 'password123');
    fireEvent.click(screen.getByRole('button', { name: /submit/i }));

    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });
});

Why it works: Specifying both Jest and Testing Library ensures the AI uses modern testing practices (userEvent, findBy queries) rather than outdated Enzyme patterns.

12. Create a Dynamic Import Component with Next.js

Prompt:

Create a dynamic import for a heavy chart component in Next.js. It should only load on the client side, show a loading spinner while loading, and handle the case when the module fails to load.

Usage example:

import dynamic from 'next/dynamic';
import { Suspense } from 'react';

const DynamicChart = dynamic(
  () => import('../components/Chart'),
  {
    loading: () => <div className="flex justify-center items-center h-64"><Spinner /></div>,
    ssr: false,
  }
);

function ChartErrorFallback() {
  return (
    <div className="p-4 bg-red-100 text-red-700 rounded">
      Failed to load chart component.
    </div>
  );
}

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <ErrorBoundary fallback={<ChartErrorFallback />}>
        <Suspense fallback={<div className="flex justify-center items-center h-64"><Spinner /></div>}>
          <DynamicChart />
        </Suspense>
      </ErrorBoundary>
    </div>
  );
}

Why it works: The prompt asks for client side only (ssr: false), loading spinner, and error handling — all best practices for dynamic imports in Next.js.

Bringing It All Together

These 12 prompts cover the most common scenarios in React and Next.js development: from component generation and state management to server-side rendering and testing. The key takeaway is that effective prompts are specific — they include concrete requirements, edge cases, and desired patterns.

In my experience, the time spent crafting a good prompt pays back tenfold in the quality of the generated code. Next time you need a new component, hook, or test, try using one of these prompts as a template. Adjust the details to match your project, and you'll get production-ready code in seconds.

If you have a prompt that works particularly well for you, share it with the community. The best prompts often come from real-world use cases.

← All posts

Comments