How We Guide LLMs to Write Better Mobile Code: Inside Our Prompt Engineering
(156 chars):** Prompt engineering for code generation is a systems problem. Here are 7 patterns we run in production to guide LLMs to write reliable mobile app code.
By Riya
23rd Sep 2026
Last updated: 23rd Sep 2026
Ask most engineers what prompt engineering for code generation means and you'll hear something about clearer instructions, better examples, and maybe a system prompt. That's the shallow version. The deeper reality — the one you learn only after shipping tens of thousands of AI-generated React Native apps — is that a good prompt is roughly ten percent of the job. The other ninety percent is the machinery around it: the plan the model must follow, the tools that constrain what it can write, the validators that catch mistakes before they hit the preview, and the feedback loops that teach the model to fix its own errors mid-turn.
At RapidNative, we turn natural-language prompts into production-ready React Native and Expo apps. This post walks through the seven patterns that keep our LLM output correct, safe, and shippable — patterns that took us from "the model generated a screen that looked right but returned zero rows in production" to a preview that boots on first try in the vast majority of runs.
Prompt engineering for code generation is a systems problem, not a writing problem — Photo by Christopher Gower on Unsplash
The Naive Loop, and Why It Breaks
The first thing anyone builds when they attempt an AI code generator is a single-shot pipeline: user prompt goes in, system prompt is prepended, the LLM streams code out, and the client renders it. It works for the demo. It falls over the moment a real user asks for anything with state, navigation, or a database.
The failure modes are well-known if you've spent time in this space:
- The model writes a screen that references a table that doesn't exist yet.
- The model creates the table but forgets a row-level security policy, so every query silently returns zero rows.
- The model writes valid TypeScript that imports a package the project doesn't have.
- The model streams a
.tsxfile with JSX into a.tsextension and the bundler refuses to touch it. - The model generates seven screens in one turn, blows past the context budget on the follow-up, and starts hallucinating file paths.
You cannot fix these with a longer system prompt. Every rule you add competes with every other rule for the model's attention. Beyond a certain size the prompt becomes a suggestion box, not a specification. The fix is to move constraints out of prose and into structure — into the plan, the tools, and the validators. That's what the rest of this post is about.
Pattern 1: Plan-Then-Execute With Two Models
Our first move was to stop asking one model to do two jobs. Deciding what an app needs and writing how to build it are different cognitive tasks, and they should not share a context.
The pipeline runs a fast planner first — a reasoning-capable model called at low effort — whose only job is to output a structured plan. The plan has fixed sections: TABLES, SCREENS, LAYOUT, THEME, DESIGN, SEED, DEFERRED. Each section has a shape the executing model expects. The planner never touches a file.
The plan is then injected into the main model's system prompt inside <plan> tags, and — this is the critical part — the main model is told:
Follow the plan above exactly. Do NOT deliberate on alternatives — the decisions are made.
When a plan is present, we also disable thinking on the executor. The whole point of the plan was to do the deliberation once, cheaply, in a scope where the model wasn't also holding a mental model of the file system, the type generator, and Nativewind class names. Letting the executor re-litigate the plan wastes tokens and, more importantly, introduces drift.
This pattern paid off in one non-obvious place: follow-up prompts. When a user says "add a settings screen", the planner is fed the existing screen list and explicitly told NEVER put an existing screen path under CREATE. Without that guard, the model would happily name index.tsx for the new settings screen and silently overwrite the home screen. With it, index.tsx never appears under CREATE, and if the executor tries anyway, the file tool refuses.
Separating the plan from the execution is the single biggest lever in prompt engineering for code generation — Photo by Jason Goodman on Unsplash
Pattern 2: Tools Are Prompts
Every AI code generator we've seen treats the system prompt as the primary source of guidance for the model. Ours treats tools as the primary source, and the system prompt as the framing around them.
Here's what that means concretely. If we want the model to write a database migration, we don't tell it, in prose, that migrations should be idempotent, that RLS should be enabled in the same file, and that the demo user's ID is a specific UUID. We give it a tool called db_migration_new, whose schema is a two-line signature ({ name: string, sql: string }), and whose description — the string the model sees at tool-use time — carries all that guidance. The tool description then names the validator, so the model understands why it will get errors back.
Our production stack has a handful of tools the model gets exposed to per turn:
db_migration_new— write a SQL migration; validated in an in-memory Postgres before being written to diskdb_seed— writeseed.sql; validated in a throwaway engine; auto-prepends the demo user if missingdb_tables,db_describe,db_sql— read-only introspection against the current schemabegin_write_fileandwrite_file_content— a two-step file write that lets the client render partial content while the file streamsedit_file— targeted string replacement with exact-match enforcement
The reason we split file writes into two steps deserves its own paragraph. begin_write_file declares the path first — that's when we check the hard caps, warn about overwrites, and reserve the path. write_file_content streams the body. The UI can render the file live as it streams, because it already knows the path. Merging them into one call would either force the model to commit to a path before it had thought through the content, or force the UI to guess.
Pattern 3: Validate Before Write
The single biggest source of "the model looked confident but produced broken code" is that the model has no way to know whether its code will actually run. It writes into a void and hopes.
Our answer is to close that loop inside the tool. When the model calls db_migration_new, the tool doesn't just save the SQL — it applies the migration on top of the existing chain of migrations in an in-memory Postgres engine (we use PGlite, real Postgres compiled to WASM) before the file is written. If the migration errors — foreign key to a non-existent table, syntax error, conflict with an earlier migration — the tool returns an error the model can read.
We used to use a lighter Postgres subset for this. It cost us a shipped bug: a project's first migration declared profiles.id as text and used auth.uid() = id in its RLS policies. The subset accepted the comparison; real Postgres has no uuid = text operator and refuses the whole migration at policy creation. The user's tables, seed, and screens all failed silently. The validator being more permissive than the runtime is worse than having no validator at all — it hides the bug behind a green light.
The lesson generalizes: if you're going to validate LLM output before shipping it, the validator has to be at least as strict as production. Any looser and you've built a lie detector that agrees with everyone.
Pattern 4: Feedback Loops Belong in Tool Results
The naive way to teach a model to fix its own mistakes is to reject the tool call and let the model retry. That works, but it wastes turns and often loses context. The pattern we use is different: the tool accepts the call, applies whatever succeeded, and returns a structured error alongside the success. The model sees both.
A typical db_migration_new response might look like:
{
ok: true,
needsFix: true,
lints: "ERROR: 'posts' has RLS enabled but no policies, so every
query returns zero rows and the app will look broken with
no error. Add a policy in the same migration.
WARNING: foreign key column 'posts.user_id' has no index —
will seq-scan under load."
}
ok: true means the migration is on disk and the schema advanced. needsFix: true and the lints string are the model's cue that another migration is needed to complete the picture. And critically, the lints field is written for the model, not for a human — every error explains not just what's wrong but the consequence ("every query returns zero rows and the app will look broken with no error"). Models trained on natural language pick up on causal framing far more reliably than they pick up on error codes.
We capture this feedback in the run harness and can inspect exactly what the model was shown at each step. That's how we found the class of bug we thought didn't exist: warnings the model was ignoring. From the log it looked like a warning that had never fired. In fact it had fired and the model had chosen to move on.
The feedback loop between LLM tool calls and validators is where most silent bugs are caught — Photo by Mimi Thian on Unsplash
Pattern 5: The Silent-Denial Lint
Some errors don't error. They pass every check the compiler runs, they pass every check the database runs, and they still produce a broken app. Row-Level Security misconfigurations are the classic case. If you ALTER TABLE posts ENABLE ROW LEVEL SECURITY without creating any policies, Postgres accepts the DDL — and every query on posts returns zero rows forever, silently, with no exception raised anywhere in the stack.
This is where custom linters earn their keep in prompt engineering for code generation. After every migration applies successfully, we run a lint pass over the resulting schema. The RLS lint fires when a table has RLS enabled and no policies. The reserved-word lint fires when a column is named order or user or something else that will parse but blow up in unexpected joins. The missing-index lint fires when a foreign key column has no supporting index.
These lints don't reject the migration — they attach to the tool result and the model sees them on the next turn. In practice the model reads them, generates a follow-up migration that adds the missing policy or index, and moves on. We're not fighting the model; we're giving it the information it needed the first time and letting it self-correct.
The linter is opinionated on purpose. Any rule that would produce false positives at scale gets demoted to a warning; any rule that catches a class of bug we've actually shipped is an error. That distinction matters because errors set needsFix: true and warnings don't, and needsFix is what stops the run from being reported as complete.
Pattern 6: Enforce Caps in Tools, Not in Prose
Every system prompt for code generation has some version of "don't create more than five screens per turn." Nobody who has spent time on this thinks the LLM will reliably honor that constraint from prose. The way you actually enforce it is by making the tool refuse.
Our begin_write_file tool holds a per-turn counter of new screens. When the counter hits the cap, the tool returns:
{ ok: false, error: "Screen cap of 5 reached this turn. Stop and ask the user to say 'continue'." }
Because this is a tool error rather than a prose reminder, the model treats it as feedback rather than a suggestion. It stops, it summarizes what it did in the assistant message, and it hands back to the user. This same pattern applies to new files per turn, new tables, and new components. The prose in the system prompt still mentions the caps — models occasionally choose different strategies when they know the cap exists — but the tool is the source of truth.
The subtlety worth noting: the caps count new creations, not edits. A model that touches ten existing screens is not the same as a model that generates ten new screens. Existing screens are the user's home ground; new screens are novel surface area. Capping the second without limiting the first is what lets follow-up prompts feel responsive without runaway generation.
Pattern 7: Auto-Repair the Common Mistakes
Some mistakes are so common — and so mechanical — that fixing them in the tool is cheaper than teaching the model not to make them.
The auto-repairs the tools apply on every file write:
- Font imports. If the model imports from
@expo-google-fonts/*, we rewrite it toexpo-font. This normalization stems from a real bug: we'd occasionally see the model use a package we don't have installed. - Gradient tags. If the model writes
<LinearGradient>without importing it, we add the import. - JSX in
.tsfiles. If the model streams JSX into a.tsextension, we transparently rename to.tsxat write time. - Undeclared imports. Any import that resolves to a package not in
package.jsongets added to a follow-up dependency install. The model never has to reason about the dependency graph. - Demo user in seeds. If the model writes a
seed.sqlthat doesn't include the demo user's password hash, we auto-prepend a header that inserts the demo user. This is what makes the preview auto-sign-in on first render — the model doesn't have to remember.
None of these repairs are silent from the model's point of view. The write returns which normalizations happened, and the tool result includes them. When the model reads its own recent tool history, it sees that the font import got rewritten, and the next time in the same session it tends to skip the wrong pattern altogether. We're using tool results as an in-context few-shot for the second half of the turn.
The One Pattern That Runs Underneath Everything: Two Previews, One Set of Files
This one is architectural rather than prompt-level, but it shapes every prompt engineering decision above. A RapidNative project renders in two independent places: the in-editor Lifo preview (which reads the same file store the AI reads and writes) and the on-device Expo Go preview (which is a separate cloud workload that files sync to). Anything you want visible on device but not to the editor — like the pre-generation "Building your app…" loading screen — has to live in the workload, not in the files.
The failure mode this rules out is the one that used to bite us: pushing UI into a project file to make it appear on device, and then having that file leak into the editor's iframe and into the agent's ls. Once you internalize that the agent can ls and cat its own file store, you stop trying to hide state from it by filtering — the agent enumerates via shell, so any filter is a lie you're telling one layer of the system. The rule becomes: if the model shouldn't see it, don't store it as a file.
That constraint feeds back into prompt design. Our tools list is deliberately small. Our file surface is deliberately narrow. The system prompt describes only what the model is allowed to write, because everything else is either enforced by a tool refusing, a validator rejecting, or an architectural boundary the model can't cross.
What Actually Moved the Numbers
If we had to rank the seven patterns by how much they contributed to reliability, the ranking wouldn't match the order we shipped them. Plan-then-execute reduced follow-up regressions the most because it eliminated a whole class of "the model chose the wrong path" bugs. Validation-before-write and the RLS lint together eliminated the silent-broken-app category — the failure mode where the preview looks correct and the app returns nothing. Hard caps in tools eliminated context-runaway on long sessions. The prose in the system prompt, on its own, does very little; it's the scaffolding that lets the model make sense of what the tools tell it.
The takeaway for anyone else building this kind of system: don't spend a week rewriting your system prompt. Spend the week building the validator that runs after the tool call, and the linter that runs after the validator, and the harness that captures what the model was actually shown when it made its next decision. The prompt gets shorter over time, not longer. If you find yourself adding another paragraph to the system prompt to warn the model about a mistake, ask instead: what would it take to make that mistake unrepresentable in the tool schema, or auto-repaired in the tool body, or detectable by a linter?
Try It Yourself
The patterns above run in every project we generate. Describe your app in plain words — even a rough sketch or a PRD works — and you'll see the plan-then-execute flow, the validated migrations, the auto-signed-in preview, and the two-panel editor that lets you iterate on any screen by pointing at it. Start free with 20 credits at RapidNative, or read our companion posts on the multi-LLM router that picks the right model and why we run a four-step LLM pipeline for a fuller picture of the system.
If you'd rather start from a screenshot, a whiteboard sketch, or a Product Requirements Document, we support all three — see image to app, whiteboard mode, and PRD to app. Every one of them runs through the same pipeline of plans, tools, validators, and lints described here — because that's the pipeline that makes the difference between a code generator that demos well and one that ships apps.
People Also Ask
What is prompt engineering for code generation?
Prompt engineering for code generation is the practice of designing the inputs, tool definitions, and feedback loops that guide a large language model to produce correct, runnable code. It is a systems problem: the raw text of the system prompt is only one input, and in production the more important levers are the tool schemas the model can call, the validators that run on tool output, and the structured errors those validators feed back to the model on the next turn.
How do you reduce LLM hallucinations in code generation?
The most effective techniques are validating LLM output against the real runtime before the file is written, returning structured errors as tool results so the model self-corrects mid-turn, enforcing hard limits in tool schemas rather than in prose, and separating planning from execution across two models so no single context has to hold both the architectural decisions and the file-level details.
Why use tools instead of just prompts for LLM code generation?
Prompts compete for a model's attention and degrade as they grow. Tools are structural — a tool that refuses an invalid call is a hard constraint the model cannot ignore. Moving guidance from prose into tool schemas, tool descriptions, and validator feedback produces more reliable behavior than any amount of prompt-level instruction, and it scales: adding a new rule is a code change, not a prompt rewrite.
Ready to build your app?
Turn your idea into a production-ready React Native app in minutes.
Free tools to get you started
Free AI PRD Generator
Generate a professional product requirements document in seconds. Describe your product idea and get a complete, structured PRD instantly.
Try it freeFree AI App Name Generator
Generate unique, brandable app name ideas with AI. Get creative name suggestions with taglines, brand colors, and monogram previews.
Try it freeFree AI App Icon Generator
Generate beautiful, professional app icons with AI. Describe your app and get multiple icon variations in different styles, ready for App Store and Google Play.
Try it freeFrequently asked questions
What is RapidNative?
RapidNative is an AI-powered mobile app builder. Describe the app you want in plain English and RapidNative generates real, production-ready React Native screens you can preview, edit, and publish to the App Store or Google Play.
Can I export the code?
Yes. RapidNative generates clean React Native and Expo code that you can export at any time. No lock-in, no proprietary format. Hand it to your developers or keep building inside RapidNative.
Is RapidNative free to use?
Yes. You can build apps on the free plan with no credit card required. Paid plans unlock unlimited AI generations, code export, and direct publishing to the App Store and Google Play.
Do I need to know how to code?
No. Most users build apps by describing what they want in plain English. Developers can drop into the code whenever they want more control, but coding is optional.
How long does it take to build an app?
Most users have a working first screen in under a minute. A full MVP usually takes a few hours instead of the weeks or months traditional development requires.