How to Architect Intelligent UI Components in Next.js 15 for AI-Powered Experiences with TypeScript (2026)
The year 2026 promises even deeper integration of AI into user interfaces, transforming passive elements into intelligent, adaptive companions. Next.js 15, with its robust App Router and TypeScript-first approach, provides an ideal foundation for building these next-generation AI-powered experiences. This post will guide you through architecting a reusable, intelligent UI component—a "Smart Text Input with AI Suggestions"—demonstrating how to blend client-side interactivity with server-side AI logic efficiently.
1. AIQueryInput: A Smart Text Input Component
Our featured component, AIQueryInput, is a client-side React component designed to enhance user input fields with real-time, AI-powered suggestions. It intelligently debounces user typing, sends the debounced query to a backend AI service (via a Next.js API route), and presents a list of relevant suggestions. This pattern is invaluable for semantic search, prompt engineering, code assistance, or dynamic content generation UIs.
First, let's define the API route that our client component will interact with. This simulates an AI backend providing suggestions.
1.1. Backend API Route: /app/api/ai-suggestions/route.ts
This simple Next.js App Router API route will accept a POST request with a query and return mock AI suggestions. In a real-world scenario, this route would interface with an LLM (Large Language Model) API like OpenAI, Anthropic, or a custom inference endpoint.
// src/app/api/ai-suggestions/route.ts
import { NextResponse } from 'next/server';
interface AISuggestionPayload {
query: string;
}
// Simulate an asynchronous AI call
async function fetchAISuggestions(query: string): Promise<string[]> {
// In a real application, you'd call your AI service here (e.g., OpenAI API)
await new Promise(resolve => setTimeout(resolve, Math.random() * 500 + 200)); // Simulate network delay
const lowerQuery = query.toLowerCase();
if (lowerQuery.includes('nextjs')) {
return ['Next.js App Router', 'Next.js Server Components', 'Next.js 15 features'];
} else if (lowerQuery.includes('typescript')) {
return ['TypeScript generics', 'TypeScript utility types', 'TypeScript with React hooks'];
} else if (lowerQuery.includes('ai')) {
return ['AI-powered UI', 'Generative AI models', 'Responsible AI development'];
} else if (lowerQuery.length > 2) {
return [`Suggestion for "${query}" 1`, `Suggestion for "${query}" 2`, `More for "${query}"`];
}
return [];
}
export async function POST(request: Request) {
try {
const { query }: AISuggestionPayload = await request.json();
if (!query || typeof query !== 'string') {
return NextResponse.json({ error: 'Query parameter is required and must be a string' }, { status: 400 });
}
const suggestions = await fetchAISuggestions(query);
return NextResponse.json({ suggestions });
} catch (error) {
console.error('Error fetching AI suggestions:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
1.2. Client Component: AIQueryInput.tsx
This client component handles the user interface, manages input state, debounces calls to the API route, and displays the suggestions.
// src/components/AIQueryInput.tsx
"use client";
import React, { useState, useEffect, useRef, useCallback, ChangeEvent, KeyboardEvent, FocusEvent } from 'react';
// Define the props interface for clarity and type safety
interface AIQueryInputProps {
placeholder?: string;
debounceDelay?: number; // Milliseconds to wait after last input before querying AI
onSelectSuggestion?: (suggestion: string) => void;
className?: string; // Optional class for the main container
inputClassName?: string; // Optional class for the input element
suggestionsClassName?: string; // Optional class for the suggestions list
}
export function AIQueryInput({
placeholder = "Ask AI...",
debounceDelay = 500,
onSelectSuggestion,
className = '',
inputClassName = '',
suggestionsClassName = '',
}: AIQueryInputProps) {
const [inputValue, setInputValue] = useState<string>('');
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
const [focusedSuggestionIndex, setFocusedSuggestionIndex] = useState<number>(-1);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLUListElement>(null);
// Effect for debouncing and fetching suggestions
useEffect(() => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
if (inputValue.trim() === '') {
setSuggestions([]);
setIsLoading(false);
setShowSuggestions(false);
return;
}
setIsLoading(true);
setShowSuggestions(true); // Always show suggestions when loading for new input
debounceTimeoutRef.current = setTimeout(async () => {
try {
const response = await fetch('/api/ai-suggestions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: inputValue }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setSuggestions(data.suggestions || []);
setFocusedSuggestionIndex(-1); // Reset focused index on new suggestions
} catch (error) {
console.error('Failed to fetch AI suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
}, debounceDelay);
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [inputValue, debounceDelay]);
const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
setFocusedSuggestionIndex(-1); // Reset focus when typing
}, []);
const selectSuggestion = useCallback((suggestion: string) => {
setInputValue(suggestion);
setSuggestions([]);
setShowSuggestions(false);
onSelectSuggestion?.(suggestion); // Invoke prop callback
inputRef.current?.focus(); // Keep focus on input after selecting
}, [onSelectSuggestion]);
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (suggestions.length === 0 || !showSuggestions) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setFocusedSuggestionIndex(prevIndex =>
(prevIndex + 1) % suggestions.length
);
break;
case 'ArrowUp':
e.preventDefault();
setFocusedSuggestionIndex(prevIndex =>
(prevIndex - 1 + suggestions.length) % suggestions.length
);
break;
case 'Enter':
e.preventDefault();
if (focusedSuggestionIndex !== -1) {
selectSuggestion(suggestions[focusedSuggestionIndex]);
} else if (inputValue.trim() !== '') {
// If enter is pressed without selecting a suggestion, but input exists,
// it might mean "submit current query". We can add this logic here.
onSelectSuggestion?.(inputValue.trim());
setSuggestions([]);
setShowSuggestions(false);
inputRef.current?.blur(); // Optionally blur after "submission"
}
break;
case 'Escape':
setShowSuggestions(false);
setFocusedSuggestionIndex(-1);
inputRef.current?.blur();
break;
}
}, [suggestions, focusedSuggestionIndex, showSuggestions, inputValue, selectSuggestion, onSelectSuggestion]);
const handleInputFocus = useCallback(() => {
if (inputValue.trim() !== '') {
setShowSuggestions(true);
}
}, [inputValue]);
const handleInputBlur = useCallback((e: FocusEvent<HTMLInputElement>) => {
// Check if the blur event is not due to clicking a suggestion
if (suggestionsRef.current && !suggestionsRef.current.contains(e.relatedTarget as Node)) {
// Small delay to allow click events on suggestions to register
setTimeout(() => {
setShowSuggestions(false);
setFocusedSuggestionIndex(-1);
}, 100);
}
}, []);
return (
<div className={`relative ${className}`} style={{ minWidth: '300px' }}>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
placeholder={placeholder}
aria-autocomplete="list"
aria-controls="ai-suggestions-list"
aria-expanded={showSuggestions && (suggestions.length > 0 || isLoading)}
className={`w-full p-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${inputClassName}`}
style={{ paddingRight: isLoading ? '30px' : '10px' }} // Space for loader
/>
{isLoading && (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 rounded-full border-2 border-t-2 border-gray-200 border-t-blue-500 animate-spin"
aria-label="Loading suggestions"
></span>
)}
{showSuggestions && (suggestions.length > 0 || isLoading) && (
<ul
id="ai-suggestions-list"
role="listbox"
ref={suggestionsRef}
className={`absolute z-10 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto ${suggestionsClassName}`}
>
{isLoading && suggestions.length === 0 && (
<li className="p-2 text-gray-500 italic" role="option" aria-live="polite" aria-busy="true">Loading suggestions...</li>
)}
{suggestions.map((suggestion, index) => (
<li
key={suggestion}
role="option"
aria-selected={index === focusedSuggestionIndex}
onClick={() => selectSuggestion(suggestion)}
onMouseDown={(e) => e.preventDefault()} // Prevent blur on input when clicking suggestion
className={`p-2 cursor-pointer hover:bg-blue-50 ${index === focusedSuggestionIndex ? 'bg-blue-100' : ''}`}
>
{suggestion}
</li>
))}
</ul>
)}
</div>
);
}
1.3. Usage in a Page Component
To use the AIQueryInput component, simply import it into any client-side page or component in your Next.js App Router application.
// src/app/page.tsx
// This is a Server Component, but we render a Client Component inside it.
import { AIQueryInput } from '../components/AIQueryInput'; // Adjust path as needed
export default function HomePage() {
const handleSuggestionSelect = (suggestion: string) => {
console.log("Selected AI suggestion:", suggestion);
// Here you would typically process the selected suggestion
// e.g., update a form field, trigger a search, send to another AI process.
alert(`You selected: "${suggestion}"`);
};
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24 bg-gray-50">
<div className="z-10 w-full max-w-xl items-center justify-between font-mono text-sm lg:flex flex-col gap-4">
<h1 className="text-3xl font-bold mb-6 text-gray-800">Intelligent Query Interface (2026)</h1>
<p className="mb-4 text-gray-600">Type below to get AI-powered suggestions:</p>
<AIQueryInput
placeholder="e.g., Next.js 15 features, TypeScript generics, AI-powered UI"
onSelectSuggestion={handleSuggestionSelect}
className="w-full"
inputClassName="text-base"
/>
<p className="mt-8 text-gray-500 text-xs">
This component demonstrates a pattern for integrating AI into a modern Next.js application,
leveraging client-side interactivity and server-side AI processing.
</p>
</div>
</main>
);
}
Architecting intelligent UI components like AIQueryInput in Next.js 15 requires a thoughtful blend of client-side reactivity and efficient server-side AI integration. By using TypeScript, we ensure type safety and maintainability. The App Router's clear separation between client and server components, combined with powerful features like API routes (or Server Actions for more direct integration), makes Next.js an excellent platform for building the AI-powered user experiences of tomorrow. As AI capabilities evolve, such modular and intelligent components will become the building blocks of truly adaptive and intuitive applications.
📚 More Resources
Check out related content:
Looking for beautiful UI layouts and CSS animations?
🎨 Need Design? Get Pure CSS Inspiration →
Comments
Post a Comment