From Prompt to Production: A Real Next.js Vibe Coding Walkthrough
A real walkthrough of building a Next.js feature from a single prompt all the way to a deployed URL — annotated with what I accepted, rejected, and why. The actual path, not the happy path.

Most vibe coding tutorials show you the happy path. This is the actual path — including the parts where the AI was wrong, the moments I had to push back, and the decisions that look obvious in retrospect but weren't at the time.
The goal: ship a feature that lets users paste a URL and get back a clean reading view of the article. One page. One input. One result. Live URL at the end. Deployed on Vercel, real working software, in under one hour of focused work.
This walkthrough captures what actually happened — the prompts I sent, the diffs I accepted, the diffs I rejected, and the why behind each move.
TL;DR — the lessons
- Read every line. Especially API routes. Especially anything that touches auth, money, or external services.
- Reject unknown dependencies aggressively. "Use only built-ins" is a magic phrase that saves an hour of supply-chain anxiety.
- Ask for edge cases explicitly. Models default to happy paths unless you pull them off them.
- Ship in under an hour or the friction wins. If you stretch this across three days, you will abandon it.
- Commit between every logical change. Git is your undo button.
The full walkthrough is below.

Step 1 — the first prompt
I opened Cursor, opened a fresh Next.js project (App Router, TypeScript, Tailwind), and typed:
Build a page at/reader. It has a single URL input. When the user submits, call a new API route at/api/readerthat fetches the URL, extracts the main article text, and returns it as JSON. Render the result as a clean reading view with a max-width of 70ch and comfortable line height.
What Cursor produced: three files — the page, the API route, and a loader component.
What I accepted: the page layout and the loader. Both were fine. The page was a centered single-input form with a Submit button; the loader was a simple spinner with a backdrop.
What I rejected: the API route. Cursor had reached for an npm package I had never heard of (@mozilla/readability plus jsdom). Both are real, both work — but I did not want to install two unfamiliar packages into my repo because a model confidently suggested them. Especially not for a feature this small.
Why this matters: AI editors love to reach for libraries. Each new dependency is a supply chain risk, a maintenance commitment, and a future security audit item. The rule for me: reject by default, accept on case-by-case after a 30-second scan of the package's repo, weekly downloads, and last-commit date. See 13 vibe coding security mistakes for the broader pattern.
Step 2 — pushing back
I replied:
Rewrite the API route using only Node built-ins. Usefetchto get the HTML and a simple regex or DOMParser to pull the<article>or main content. Do not install anything.
What Cursor produced: an API route with no dependencies. It used fetch for the HTTP request and a basic regex pattern to extract the <article> element, falling back to <main> and then to <body>.
What I accepted: most of it. The structure was right.
What I noticed: the route had no timeout. If the target URL hung, the user would wait forever. The route also didn't follow redirects properly — fetch follows them by default, but it didn't cap the count, so a redirect loop would lock up the request. And there was no content-type check — it would happily try to extract an article from a PNG.
The lesson: the model produces happy-path code by default. Asking for edge cases is a separate prompt.

Step 3 — the edge cases
I asked:
Add a 10 second timeout. Follow redirects up to three times. Return a helpful error JSON with a reason field if the URL is unreachable, if the response is not HTML, or if no article content is found. Log nothing to the console in production — only in development.What Cursor produced: a hardened version of the route with AbortController for the timeout, a manual redirect counter, content-type validation, and a structured error response.
What I accepted: all of it. The error responses now had { ok: false, reason: 'timeout' | 'not-html' | 'no-content' | 'fetch-failed' } shapes — exactly what a UI needs to show useful messages.
What I noticed but accepted anyway: the timeout was implemented as an AbortController inside a Promise.race, which is the standard pattern. Looked correct.
This is the step most vibe coders skip. Edge-case hardening is boring but it is the difference between "demo software" and "software a human would actually trust." The 10 minutes spent here saved hours of debugging later.
Step 4 — the UX pass
I ran it locally. Pasted a real article URL. It worked.
Then I pasted a garbage URL. The UI just sat there silently — no error, no feedback. The API was returning a structured error; the UI was ignoring it.
I asked:
Add loading and error states to the UI. Show a spinner while fetching. Show the error message from the API below the input in red, mapped to friendly text from the reason field. Disable the submit button while loading. Don't lose the URL the user typed when an error happens.What Cursor produced: a refactor of the form that added useState for status: 'idle' | 'loading' | 'error' | 'success', mapped each reason to a friendly string, and gated the submit button on the loading state.
What I accepted: all of it.
What I noticed: the model wanted to clear the input after a successful submit. I overrode — for a reading tool, you might want to paste a sequence of URLs from a notes app, and clearing creates friction.
The lesson: UX details matter and the model defaults to "tidy" rather than "fast for the user." Watch for over-tidiness.
Step 5 — shipping
I committed:
`` git add . git commit -m "feat: reader page with URL input and article extraction" git push origin main ``
Vercel picked up the push and deployed in 45 seconds. I had a live URL with a working feature.
Total elapsed: 38 minutes from blank repo to deployed feature.
What I did NOT do (and why)
The walkthrough above is honest about every step. Here are the steps I deliberately skipped, with the trade-offs:
- I did not ask the model to write tests. For a personal utility, I skip tests on the first pass. If this were going into a production codebase, tests are non-negotiable.
- I did not add auth. Anyone can hit the URL. For this use case, that is fine — the feature is a public reading view.
- I did not add a database. Results are not persisted. If the user wants to come back to a saved article, they bookmark the original URL. Adding a database for "saved articles" would have doubled the scope.
- I did not add rate limiting. I should. I added it the next day with a 5-line Upstash Redis middleware. The reason I shipped without: rate limiting is a 15-minute job and shipping with the gap was acceptable for the first hour.
- I did not add an Open Graph image or social meta. Same logic — shippable without, can add when traffic justifies.
The point: shipping is a series of "good enough for now" decisions, not "complete" ones. Each item I skipped was a deliberate trade-off, not an oversight.

The actual transcript pattern
For the writers/builders reading this who want to internalize the prompt rhythm, the pattern across all five steps was:
- Goal-stating prompt — what I want, scope-bounded.
- Read the diff. Especially scan for unfamiliar imports and unfamiliar function calls.
- Reject specific things in plain language — "use only built-ins," "do not install."
- Ask for edge cases as a separate prompt. Models do not infer "and handle errors gracefully."
- Run it locally before committing. Click the unhappy paths.
- Commit each logical change so git is the safety net.
- Ship.
This rhythm is the same regardless of whether the editor is Cursor or Claude Code (see the head-to-head). It applies to backend refactors, frontend builds, infrastructure changes, anything.
The lessons, expanded
Read every line
There is a trap in vibe coding where the speed feels so good that you start accepting diffs without reading them. That is when the bugs land. The 30 seconds you spend reading a diff catches the dependency you didn't want, the import that's wrong, the variable that shadows the parameter.
Reject dependencies aggressively
"Use only Node built-ins" is the magic phrase that saves an hour of supply-chain anxiety. If the feature is small, the answer is almost always "no new packages." If the feature is genuinely complex, accept dependencies on case-by-case basis after a 30-second look at the package.
Ask for edge cases explicitly
The model defaults to the happy path. If you do not say "handle the case where the input is empty" or "what if the network times out," you do not get those cases handled. Make a habit of always sending an edge-cases prompt as a separate step.
Ship in under an hour or the friction wins
The longer a feature stretches across days, the more likely it is to die. Time pressure is the single biggest reason indie features ship. Without it, "I'll get to it tomorrow" wins.
Commit between every logical change
Git is your undo button. git reset --hard HEAD~1 is the cheapest way to recover from an agent that confused itself. The cost of "too many small commits" is zero. The cost of "one giant commit you can't unwind" is real.
Common questions
Why Next.js specifically for this walkthrough?
Next.js is the framework with the most vibe-coder gravity in 2026. AI editors produce better code in Next.js than in any other framework because they were trained on more of it. Same workflow applies to Vue, SvelteKit, Remix, etc. — but the friction is lower in Next.js right now.
Could I do this in Cursor or Claude Code?
Both work. I used Cursor here because the project is UI-heavy (the form, the reading view) and Cursor's preview-on-the-side ergonomics matter for that. For a backend-only walkthrough I would have used Claude Code. See the comparison.
How is this different from "just" using AI as autocomplete?
The interaction model. Autocomplete is line-by-line — you type, the model finishes. Vibe coding is goal-by-goal — you describe an outcome, the model proposes an implementation, you accept or steer. You write fewer keystrokes. You read more diffs.
What about TypeScript types?
I had TypeScript on, with strict mode. Cursor produced typed code by default. Where it produced any, I asked for a stricter type and accepted the corrected version. TypeScript and AI editors are mutually reinforcing — TypeScript catches mistakes the model makes, the model writes types you would otherwise skip.
How do you handle "the model confidently invents a function"?
You catch it by reading the diff. If a function call is to a method you don't recognize, hover for the type signature in your editor. If the editor shows it as undefined or imported from a package you didn't install, the model invented it. Push back: "the function extractText doesn't exist — write the implementation inline."
What if the model gets stuck in a loop?
Stop the session. Reset the conversation. Re-prime with a fresh NOTES.md describing the project state. Models compound their own context — once they go wrong, they often double down. Better to start fresh.
What's the longest single feature you'd build this way?
A few hours of focused work, then commit + reset + new session. Sessions over 4-5 hours start losing coherence even with strong context management. Better to break long work into multiple sessions, each with a clean start.
The bottom line
A real feature, built by steering, deployed to production in under an hour. The difference between you doing this and not doing this is one tab in one editor.
For more context on the workflow that fits around walkthroughs like this: What is vibe coding, The vibe coder's stack 2026, 11 Claude Code tricks.
For the security pitfalls that catch builders moving this fast: 13 vibe coding security mistakes. For weekly AI-tooling coverage: humanai.news. To deploy a personal AI agent in 60 seconds: RapidClaw.