50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
vibecodingMay 21, 2026·8 min read

Prompt Engineering for Code Generation: What Actually Works

Stop writing vague prompts and getting vague code. Here's how we structure prompts to get output we can ship.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Prompt Engineering for Code Generation: What Actually Works

There's a version of prompt engineering that's all about jailbreaks, magic words, and "act as a senior engineer with 20 years of experience." That's not what this post is about. This is about the boring, practical stuff that actually makes AI write better code — the habits Stefan and I developed after using Claude and GPT to ship real features to real users.

We're going to skip the philosophy and get into the specific patterns that work. What to include in your prompts, how to structure them, and the mistakes that cost you the most time (usually by producing code that looks correct at a glance but silently breaks at the edges).

The core problem: AI is a very confident guesser

Here's the thing about LLMs — they will always produce something. They don't say "I don't have enough information to answer this." They fill in the blanks with whatever seems most plausible based on training data. So if your prompt is ambiguous, you get code that's plausibly correct for some project, just not necessarily yours.

That's the root cause of most bad AI code output. Not the model being dumb — the model being confidently wrong about which assumptions to make. Your job as the prompter is to remove as many assumptions as possible.

Vague prompt → plausible code for some project. Specific prompt → actual code for your project.

Start with context, not the task

Most people start their prompts with what they want. "Write a function that..." or "Create a component that..." The model then has to guess everything else: what framework you're using, what your types look like, what patterns the rest of your codebase follows, what already exists.

Flip the order. Start with context, then ask for the thing. Here's the structure we use:

  • Tech stack (framework, language, major libraries — be specific with versions if it matters)
  • Existing types or interfaces the new code needs to work with
  • Conventions already in the codebase (naming, file structure, error handling patterns)
  • What the new code should NOT do (constraints are gold)
  • The actual task

A prompt that follows this structure takes 30 more seconds to write. It saves you 10 minutes of editing code that used the wrong import pattern, returned the wrong shape, or invented a utility function that already exists.

Paste real code, not descriptions of code

This one took us embarrassingly long to internalize. Instead of describing what your existing code does, just paste it. The model will understand your actual patterns instead of guessing at them.

Say you want to add a new API route handler. Don't write "I have a Next.js API route that validates input with Zod and returns JSON." Paste an existing route handler. The model will match your exact style — same error handling, same response shape, same way you're pulling auth context.

// Instead of describing this pattern, paste it:
export async function POST(req: Request) {
  const session = await getServerSession(authOptions);
  if (!session?.user?.id) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  const body = await req.json();
  const parsed = updateProfileSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json({ error: parsed.error.flatten() }, { status: 400 });
  }

  const updated = await db
    .update(users)
    .set(parsed.data)
    .where(eq(users.id, session.user.id))
    .returning();

  return Response.json({ user: updated[0] });
}

// Then say: "Write a similar route for updating notification preferences.
// The schema should validate: emailNotifications (boolean), marketingEmails (boolean).
// The table is `user_preferences` with columns user_id, email_notifications, marketing_emails."

You get back code that's actually consistent with your codebase. No guessing, no translating from some generic pattern the model pulled from Stack Overflow circa 2021.

Tell it what not to do

Negative constraints are underused. When you tell the model "don't use X," you're cutting off entire branches of possible output and steering toward what you actually want.

Common things worth explicitly excluding:

  • "Don't use useEffect for this" (when you know there's a better pattern)
  • "Don't install any new packages" (when you want a solution with what you have)
  • "Don't use try/catch, use the Result pattern we already have"
  • "Don't add comments explaining what the code does — the code should be self-explanatory"
  • "Don't create a new type for this, use the existing User type from @/types"

That last one about comments is worth expanding. By default, AI loves to add comments. "// Get the user from the database" before a line that obviously gets the user from the database. If your team writes clean, self-documenting code, tell the model to match that. You'll save time deleting comments.

Ask for the skeleton first, then fill it in

For anything complex — a full feature, a multi-step form, a stateful component with several interactions — asking for everything at once usually produces bloated, overengineered code. The model tries to handle every edge case it can imagine, and half of them aren't edge cases in your actual application.

Better approach: ask for the skeleton (structure, types, function signatures) first. Review it. Then ask it to implement one piece at a time.

// Prompt 1: "Give me the TypeScript interface and function signatures
// for a useSubscription hook that manages a user's billing state.
// It should handle: loading state, current plan, usage limits, upgrade/downgrade.
// Don't implement the functions yet, just the types and signatures."

// You review the shape, correct anything that doesn't match your data model.

// Prompt 2: "Now implement fetchSubscription. It should call /api/subscription
// and update the hook state. Use the existing fetcher utility:
// [paste your fetcher here]"

// Prompt 3: "Now implement handleUpgrade. It should..."

// This approach produces cleaner code than asking for everything upfront.

You catch type mismatches early, before they're baked into 200 lines of implementation. It's the same reason we do design before we code — catching mistakes at the sketch stage is cheaper than catching them after you've built the wall.

Give it the error message, not a description of the error

When you're debugging, paste the full error. Not "I'm getting a type error," not "it's throwing something about undefined" — the actual stack trace, the actual error message, the actual line number.

Then paste the relevant code. Not a simplified version — the actual code. Stefan made the mistake of simplifying a reproduction case for a particularly annoying Drizzle ORM query bug, and the AI kept suggesting fixes for the simplified version that didn't apply to the real one. We burned 20 minutes on that.

// Bad prompt for debugging:
// "I'm getting a type error when trying to use my query result"

// Good prompt for debugging:
// Here's the error:
// Type '{ id: string; name: string | null; }[]' is not assignable to
// type 'UserWithProfile[]'.
//   Type '{ id: string; name: string | null; }' is not assignable to type
//   'UserWithProfile'.
//     Property 'profile' is missing in type '{ id: string; name: string | null; }'
//     but required in type 'UserWithProfile'.
//
// Here's the query:
const users = await db
  .select({
    id: usersTable.id,
    name: usersTable.name,
  })
  .from(usersTable);

// Here's the type:
type UserWithProfile = {
  id: string;
  name: string | null;
  profile: {
    avatarUrl: string | null;
    bio: string | null;
  };
};

// The model can now see exactly what's wrong (missing join) instead of guessing.

Iterating: when to push the same prompt vs. start fresh

Once a conversation goes wrong — the model made a wrong assumption in message 3, and now it keeps defending that assumption — starting a fresh conversation is often faster than correcting in-thread. The model will try to reconcile all previous context, which sometimes means it keeps the wrong foundation and just adjusts around it.

The rule we use: if you've corrected the same thing twice and it keeps coming back wrong, start fresh with a better initial prompt that bakes in the correction from the start. Don't fight the context window — work with it.

On the other hand, follow-up prompts work really well for:

  • "Now add error handling for the case where X is null"
  • "Make this work for both authenticated and guest users"
  • "Extract the validation logic into a separate function"
  • "Add loading and error states to this component"
  • "Write a test for the edge case where the user has no subscription"

These work because you're extending correct code, not correcting wrong assumptions. The model has good context and you're just adding to it.

The meta-prompt trick: ask it how to ask

This one sounds a bit recursive, but it's useful when you're stuck on something complex and not sure how to approach it. Ask the model: "I need to implement X. Before writing any code, what information do you need from me to do this well?"

It'll surface the ambiguities itself. You answer them, then it writes the code with all the right context already in place. We've used this for things like designing database schemas and planning auth flows — anything where the design space is large and getting the wrong answer means a painful rewrite.

"What do you need to know before writing this?" is often a better first prompt than actually asking for the code.

These patterns took us a while to develop, mostly through trial and error on real projects. If you're building a SaaS on Next.js and want a head start on the boilerplate side of things, peal.dev templates have a lot of the patterns we've refined already baked in — auth, billing, emails — so you can focus on prompting AI for your actual product logic instead of the infrastructure stuff.

The one thing that will improve your prompts immediately

If you do nothing else from this post, do this: before you submit a prompt, read it as if you're the model and you've never seen your codebase. Ask yourself: "What would I assume here? What's ambiguous?" Then fill in those gaps.

It takes 30 extra seconds. It saves you 10 minutes of editing. Over a full workday of AI-assisted coding, that math adds up to a lot of time you can spend on things that actually require your brain.

Good prompts aren't magic. They're just specific, contextual, and honest about constraints. The same things that make a good ticket in a project management tool, or a good question on Stack Overflow. The skill transfers — you've probably already been doing parts of it without realizing it.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders