Create a Prompt Generation Application Using Gemini APIGEMINI_API_KEY=AIzaSyD46hdKNGrFfb5A83f2203G1ZkNfQUr4GAuse vercels ai-sdk for gen ai capabilities
Most appropriate block: A single Next.js App Router Code Project to implement the full UI and an API route that calls Gemini through the AI SDK.
</Thinking>
Below is a complete Next.js App Router app that implements the guided prompt builder with a live Gemini-powered preview, built using Shadcn UI. It uses Vercel’s AI SDK with the Google provider to call Gemini 2.5 Flash for fast, iterative updates. The server route maps GEMINI_API_KEY to the provider’s expected GOOGLE_GENERATIVE_AI_API_KEY, so you can use the key you provided after deployment. The AI SDK provides a unified interface across providers, and we call generateText with a system plus prompt for better control of Gemini’s output 123.
Important:
Add your key as an environment variable in your deployment dashboard:
tsx file="app/page.tsx" 'use client'
import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/hooks/use-toast' import { cn } from '@/lib/utils' import { Copy, Trash2, ThumbsUp, ThumbsDown, Sparkles, Loader2 } from 'lucide-react' import React from 'react'
type CategoryKey = 'creative' | 'coding' | 'business' | 'general'
type ToneOption = | 'Formal' | 'Casual' | 'Witty' | 'Professional' | 'Friendly' | 'Academic' | 'Neutral'
type LengthOption = 'Short' | 'Medium' | 'Long'
type Answers = { category: CategoryKey goal: string topic?: string audience?: string tone?: ToneOption format?: string length?: LengthOption keywords?: string constraints?: string outputFormat?: string additionalContext?: string // Coding specific language?: string problem?: string codeContext?: string }
const defaultAnswers: Answers = { category: 'creative', goal: '', topic: '', audience: '', tone: 'Professional', format: '', length: 'Medium', keywords: '', constraints: '', outputFormat: '', additionalContext: '', language: '', problem: '', codeContext: '', }
function useDebouncedCallback<T extends any[]>( callback: (...args: T) => void, delay = 600 ) { const timer = React.useRef<number | null>(null) return React.useCallback( (...args: T) => { if (timer.current) window.clearTimeout(timer.current) timer.current = window.setTimeout(() => { callback(...args) }, delay) }, [callback, delay] ) }
function buildBaselinePrompt(a: Answers) {
const parts: string[] = []
parts.push(Act as an expert assistant for category: ${a.category}.
)
if (a.goal) parts.push(Primary goal: ${a.goal}.
)
if (a.topic) parts.push(Topic: ${a.topic}.
)
if (a.audience) parts.push(Target audience: ${a.audience}.
)
if (a.tone) parts.push(Tone: ${a.tone}.
)
if (a.format) parts.push(Desired format: ${a.format}.
)
if (a.length) parts.push(Length: ${a.length}.
)
if (a.language) parts.push(Programming language: ${a.language}.
)
if (a.problem) parts.push(Problem statement: ${a.problem}.
)
if (a.keywords) parts.push(Keywords: ${a.keywords}.
)
if (a.constraints) parts.push(Constraints: ${a.constraints}.
)
if (a.outputFormat) parts.push(Output formatting: ${a.outputFormat}.
)
if (a.additionalContext) parts.push(Additional context: ${a.additionalContext}.
)
if (a.codeContext) parts.push(Existing code:\n${a.codeContext}
)
parts.push(
'Provide a clear, step-by-step response and verify the solution meets the goal and constraints.'
)
return parts.join('\n')
}
export default function Page(): JSX.Element {
const [answers, setAnswers] = React.useState<Answers>
(defaultAnswers)
const [optimizedPrompt, setOptimizedPrompt] = React.useState<string>
('')
const [isLoading, setIsLoading] = React.useState<boolean>
(false)
const [lastUpdated, setLastUpdated] = React.useState<string>
('')
const [feedback, setFeedback] = React.useState<'up' | 'down' | null>(null)
const { toast } = useToast()
const baseline = React.useMemo(() => buildBaselinePrompt(answers), [answers])
const canGenerate = React.useMemo(() => { // Minimum inputs before we ping AI: if (!answers.category) return false if (!answers.goal || answers.goal.trim().length < 4) return false // For coding, prefer at least language or problem if (answers.category === 'coding' && !answers.problem && !answers.language) return false return true }, [answers])
const generate = React.useCallback( async (payload: Answers) => { try { setIsLoading(true) setFeedback(null) const res = await fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ answers: payload }), }) if (!res.ok) { const err = await res.json().catch(() => ({})) throw new Error(err?.error || 'Failed to generate prompt') } const data = await res.json() const prompt = typeof data?.prompt === 'string' && data.prompt.trim().length > 0 ? data.prompt : '' if (prompt) { setOptimizedPrompt(prompt) setLastUpdated(new Date().toLocaleTimeString()) } } catch (e: any) { console.error(e) toast({ title: 'Generation error', description: e?.message || 'Unable to reach the AI service. Falling back to a basic prompt preview.', variant: 'destructive', }) } finally { setIsLoading(false) } }, [toast] )
const debouncedGenerate = useDebouncedCallback((payload: Answers) => { if (canGenerate) generate(payload) }, 700)
React.useEffect(() => { if (canGenerate) debouncedGenerate(answers) // eslint-disable-next-line react-hooks/exhaustive-deps }, [answers, canGenerate])
function resetAll() { setAnswers(defaultAnswers) setOptimizedPrompt('') setLastUpdated('') setFeedback(null) }
function copyPrompt() { const text = (optimizedPrompt?.trim()?.length ? optimizedPrompt : baseline) || '' if (!text) return navigator.clipboard.writeText(text) toast({ title: 'Copied', description: 'Prompt copied to clipboard.', }) }
return (
<main className="min-h-dvh bg-gray-50">
<header className="sticky top-0 z-10 border-b bg-white">
<div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8 flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-emerald-600" />
<span className="font-semibold">
Promptly</span>
<Badge variant="secondary" className="ml-2">
Gemini</Badge>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={resetAll}>
<Trash2 className="mr-2 h-4 w-4" />
Start over
</Button>
<Button onClick={copyPrompt}>
<Copy className="mr-2 h-4 w-4" />
Copy
</Button>
</div>
</div>
</header>
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Left: Form */}
<section>
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Guided Prompt Builder</CardTitle>
<CardDescription>
Answer a few questions and see a high-quality prompt crafted in real time.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4">
<div className="space-y-2">
<Label>Category</Label>
<Select
defaultValue={answers.category}
onValueChange={(v: CategoryKey) =>
setAnswers((s) => ({ ...s, category: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="creative">Creative Writing</SelectItem>
<SelectItem value="coding">Technical Question (Coding)</SelectItem>
<SelectItem value="business">Business Analysis</SelectItem>
<SelectItem value="general">General Chat</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>What is your main goal?</Label>
<Input
placeholder="e.g., Write a blog post about sustainable travel"
value={answers.goal}
onChange={(e) =>
setAnswers((s) => ({ ...s, goal: e.target.value }))
}
/>
</div>
{/* Category-specific fields */}
{answers.category === 'creative' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Topic</Label>
<Input
placeholder="e.g., Sustainable travel tips for families"
value={answers.topic}
onChange={(e) =>
setAnswers((s) => ({ ...s, topic: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Target audience</Label>
<Input
placeholder="e.g., Busy parents planning eco-friendly vacations"
value={answers.audience}
onChange={(e) =>
setAnswers((s) => ({ ...s, audience: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Format</Label>
<Select
value={answers.format}
onValueChange={(v) => setAnswers((s) => ({ ...s, format: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Choose a format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Blog Post">Blog Post</SelectItem>
<SelectItem value="Email">Email</SelectItem>
<SelectItem value="Tweet Thread">Tweet Thread</SelectItem>
<SelectItem value="Outline">Outline</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Keywords (comma-separated)</Label>
<Input
placeholder="e.g., eco-friendly, budget, itinerary"
value={answers.keywords}
onChange={(e) =>
setAnswers((s) => ({ ...s, keywords: e.target.value }))
}
/>
</div>
</div>
)}
{answers.category === 'coding' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Programming language</Label>
<Input
placeholder="e.g., TypeScript, Python, Go"
value={answers.language}
onChange={(e) =>
setAnswers((s) => ({ ...s, language: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Problem or function to solve</Label>
<Input
placeholder="e.g., Implement a debounced search with React"
value={answers.problem}
onChange={(e) =>
setAnswers((s) => ({ ...s, problem: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Existing code (optional)</Label>
<Textarea
placeholder="Paste any relevant code snippets"
rows={6}
value={answers.codeContext}
onChange={(e) =>
setAnswers((s) => ({ ...s, codeContext: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Desired output format</Label>
<Select
value={answers.outputFormat}
onValueChange={(v) =>
setAnswers((s) => ({ ...s, outputFormat: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Choose output format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Code with comments">Code with comments</SelectItem>
<SelectItem value="Step-by-step explanation">
Step-by-step explanation
</SelectItem>
<SelectItem value="Both">Both</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{answers.category === 'business' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Topic</Label>
<Input
placeholder="e.g., Market analysis for a new productivity app"
value={answers.topic}
onChange={(e) =>
setAnswers((s) => ({ ...s, topic: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Stakeholders / audience</Label>
<Input
placeholder="e.g., Product leadership team, marketing"
value={answers.audience}
onChange={(e) =>
setAnswers((s) => ({ ...s, audience: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Format</Label>
<Select
value={answers.format}
onValueChange={(v) => setAnswers((s) => ({ ...s, format: v }))}
>
<SelectTrigger>
<SelectValue placeholder="Choose a format" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Report">Report</SelectItem>
<SelectItem value="Slide outline">Slide outline</SelectItem>
<SelectItem value="Bullet memo">Bullet memo</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Additional data or context</Label>
<Textarea
placeholder="Paste relevant notes, data points, or constraints"
rows={6}
value={answers.additionalContext}
onChange={(e) =>
setAnswers((s) => ({ ...s, additionalContext: e.target.value }))
}
/>
</div>
</div>
)}
{answers.category === 'general' && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Topic or question</Label>
<Input
placeholder="e.g., Plan a 3-day trip to Kyoto focusing on culture"
value={answers.topic}
onChange={(e) =>
setAnswers((s) => ({ ...s, topic: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Format</Label>
<Input
placeholder="e.g., Checklist, itinerary, summary"
value={answers.format}
onChange={(e) =>
setAnswers((s) => ({ ...s, format: e.target.value }))
}
/>
</div>
</div>
)}
{/* Shared sections */}
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Tone</Label>
<RadioGroup
className="grid grid-cols-2 gap-2"
value={answers.tone}
onValueChange={(v: ToneOption) =>
setAnswers((s) => ({ ...s, tone: v }))
}
>
{['Professional', 'Formal', 'Casual', 'Friendly', 'Witty', 'Academic', 'Neutral'].map(
(t) => (
<div key={t} className="flex items-center space-x-2 rounded-md border p-2">
<RadioGroupItem id={`tone-${t}`} value={t as ToneOption} />
<Label htmlFor={`tone-${t}`} className="cursor-pointer">{t}</Label>
</div>
)
)}
</RadioGroup>
</div>
<div className="space-y-2">
<Label>Length</Label>
<RadioGroup
className="grid grid-cols-3 gap-2"
value={answers.length}
onValueChange={(v: LengthOption) =>
setAnswers((s) => ({ ...s, length: v }))
}
>
{(['Short', 'Medium', 'Long'] as LengthOption[]).map((l) => (
<div key={l} className="flex items-center space-x-2 rounded-md border p-2">
<RadioGroupItem id={`len-${l}`} value={l} />
<Label htmlFor={`len-${l}`} className="cursor-pointer">{l}</Label>
</div>
))}
</RadioGroup>
</div>
</div>
<div className="space-y-2">
<Label>Constraints (optional)</Label>
<Textarea
placeholder="e.g., Cite sources, stay under 600 words, include at least 3 examples"
rows={3}
value={answers.constraints}
onChange={(e) =>
setAnswers((s) => ({ ...s, constraints: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Output formatting (optional)</Label>
<Textarea
placeholder="e.g., Return a Markdown outline with H2 and H3 headings"
rows={3}
value={answers.outputFormat}
onChange={(e) =>
setAnswers((s) => ({ ...s, outputFormat: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label>Additional context (optional)</Label>
<Textarea
placeholder="Any extra details the model should consider"
rows={4}
value={answers.additionalContext}
onChange={(e) =>
setAnswers((s) => ({ ...s, additionalContext: e.target.value }))
}
/>
</div>
</div>
</CardContent>
</Card>
</section>
{/* Right: Live Preview */}
<section>
<Card className="sticky top-[72px] shadow-sm">
<CardHeader className="flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Live Prompt Preview</CardTitle>
<CardDescription>
Updates as you type. Optimized with Gemini when enough detail is provided.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant={feedback === 'up' ? 'default' : 'outline'} size="icon" aria-label="Thumbs up" onClick={() => setFeedback('up')}>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button variant={feedback === 'down' ? 'default' : 'outline'} size="icon" aria-label="Thumbs down" onClick={() => setFeedback('down')}>
<ThumbsDown className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="rounded-md border bg-white p-4">
{isLoading ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Crafting an optimized prompt with Gemini…</span>
</div>
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap text-sm leading-6">
{(optimizedPrompt?.trim()?.length ? optimizedPrompt : baseline) ||
'Start by choosing a category and describing your goal to see a live prompt preview.'}
</pre>
)}
</div>
<div className="mt-3 flex items-center justify-between text-xs text-muted-foreground">
<div>
{optimizedPrompt ? (
<span>Optimized by Gemini at {lastUpdated || '—'}</span>
) : (
<span>Baseline preview (no AI yet)</span>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant="outline">{answers.category}</Badge>
{answers.tone ? <Badge variant="outline">{answers.tone}</Badge> : null}
{answers.length ? <Badge variant="outline">{answers.length}</Badge> : null}
</div>
</div>
</CardContent>
</Card>
</section>
</div>
</div>
</main>
) }
tsx file="app/api/generate/route.ts" import { NextRequest } from 'next/server' import { generateText } from 'ai' import { google } from '@ai-sdk/google'
type CategoryKey = 'creative' | 'coding' | 'business' | 'general' type ToneOption = | 'Formal' | 'Casual' | 'Witty' | 'Professional' | 'Friendly' | 'Academic' | 'Neutral' type LengthOption = 'Short' | 'Medium' | 'Long'
type Answers = { category: CategoryKey goal: string topic?: string audience?: string tone?: ToneOption format?: string length?: LengthOption keywords?: string constraints?: string outputFormat?: string additionalContext?: string language?: string problem?: string codeContext?: string }
function metaPrompt(answers: Answers) {
return [
'You will craft a single, high-quality prompt for a general-purpose LLM.',
'Use the following user inputs to produce a final prompt the user can copy and paste into any LLM to achieve the best possible result.',
'Your output must be ONLY the final prompt, nothing else. No explanations.',
'',
'Guidelines:',
'- Start with an instructive role, e.g., "Act as a …".',
'- Include a concise problem statement and context distilled from the inputs.',
'- Specify constraints, tone, length, audience, and any domain or language requirements.',
'- Provide clear step-by-step instructions the model should follow.',
'- Specify the desired output format (e.g., Markdown structure, code fences, JSON schema).',
'- If relevant, include quality checks or acceptance criteria.',
'- If code is requested, prefer readable, idiomatic code with comments.',
'- Keep it succinct yet comprehensive.',
'',
'User Inputs (JSON):',
'json',
JSON.stringify(answers, null, 2),
'
',
].join('\n')
}
export async function POST(req: NextRequest) { try { const body = await req.json() const answers: Answers = body?.answers
if (!answers || typeof answers !== 'object') {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
if (!answers.goal || !answers.category) {
return Response.json(
{ error: 'Missing required fields: goal and category' },
{ status: 400 }
)
}
// Prefer GOOGLE_GENERATIVE_AI_API_KEY, fallback to GEMINI_API_KEY
const apiKey =
process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY
if (!apiKey) {
return Response.json(
{
error:
'Missing API key. Please set GOOGLE_GENERATIVE_AI_API_KEY or GEMINI_API_KEY in your environment.',
},
{ status: 400 }
)
}
// Ensure the Google provider can read the key:
process.env.GOOGLE_GENERATIVE_AI_API_KEY =
process.env.GOOGLE_GENERATIVE_AI_API_KEY || apiKey
const system =
'You are an expert prompt engineer. Given structured inputs, produce a single, masterfully crafted prompt. Output only the final prompt.'
const prompt = metaPrompt(answers)
const { text } = await generateText({
model: google('gemini-2.5-flash'),
system,
prompt,
})
return Response.json({ prompt: text ?? '' })
} catch (err: any) { console.error(err) return Response.json( { error: err?.message || 'Unexpected error' }, { status: 500 } ) } }
Notes and setup
How to run
Citations
<Actions>
<Action name="Add prompt history (V2)" description="Persist generated prompts in local storage or a database and add a history panel." />
<Action name="Enable streaming" description="Switch to streamText for token-by-token live preview of the optimized prompt." />
<Action name="Add template presets" description="Provide starting templates (e.g., Job Description, Unit Test, Slide Outline) to accelerate setup." />
<Action name="Export formats" description="Add buttons to export the final prompt to Markdown or JSON, or share via link." />
<Action name="Advanced mode" description="Expose the meta-prompt and allow power users to edit it directly." />
</Actions>
create a prototype
How to run
Notes
To configure the generation, complete these steps:
add a create prompt button and remove updates as you type functionality