Your app works on your laptop. Now picture a thousand strangers signing up tomorrow: one types garbage into every field, one forgets a password, a bot tries ten thousand logins, and your database goes down at 3 a.m. Would you sleep? Today I'll give you the eight habits that make the answer yes, with the code for each one and exactly what's enough.
The question I never asked
When I started building things, I built them from my head. No plan, no sketch. I kept going until I liked how it looked. Semantics, SEO, code security, the safety of the people using it: none of that crossed my mind.
And there's one question I never once asked: what happens with 1,000 users who have profiles? How do I protect their accounts from hackers? How do they reset a password? What do I do when my own bug hurts them? I had no answers, because I had never thought about it. Nothing had forced me to. On my laptop, there was exactly one user, and he was very polite.
That's the gap this article is about. People call it hobby code versus production code, and it sounds like a difference in talent. It isn't. It's a list, and you can learn a list.
Some words first, in case they're new. Production is the version of your app that real people use, as opposed to the copy on your own computer. Production-grade means the app behaves on purpose when things go wrong, not only when they go right. A deploy is the moment you put a new version into production.
Think of cooking dinner for your family versus opening a restaurant. The recipe can be the same. But the restaurant has to deal with the supplier who doesn't show up, the customer who walks out without paying, and the fridge that dies overnight. Nobody tastes any of that on the plate. It's still most of the job.
Here's how to read this. If you're a beginner, go top to bottom, and don't panic. You don't need all eight today, and I'll tell you when something is overkill. If you've shipped a few things, go straight to the numbered sections and check the code against your own. If you make architecture calls, look for the parts marked "For advanced readers" and the failure modes in each section.
Why tutorials never told you any of this
Every tutorial ends the same way. The form submits, the data shows up, and the video stops. That's the happy path: the one route through your code where everyone behaves and everything is up. It's easy to teach because it's short.
I finished courses like that and still felt like I knew nothing. The courses weren't lying. They just stopped exactly where the real work starts. What if the input is malformed? What if the database is asleep? The tutorial doesn't say, because on your laptop, with one friendly user, it never happens.
AI tools make this gap wider, and I say that as someone who uses them every day. You can now get to a working happy path in an afternoon. But AI can't know what's in your head, and it can hallucinate or assume things it shouldn't, so I check what it writes as far as I can. The people who think they can skip understanding are the ones who get stuck, because when it breaks, they can't tell what broke. This list is how you judge the code, no matter who or what typed it.
The idea before the code: eight questions
A good programmer thinks a few steps ahead: scaling, security, reliability, and always a Plan B. That sounds big, so let's make it small. Production-grade comes down to eight questions you ask about every feature:
- Who sent this data, and what if they lied?
- When this fails, what does the user see, and do I find out?
- What happens when someone calls this ten thousand times?
- A user reports a problem from yesterday. Can I find out what happened?
- If I break this next month, what will tell me?
- If a stranger reads my code or calls my endpoints, what can they get?
- Can I change the database without losing anyone's data?
- When a service I depend on goes down, how much of my app goes with it?
Each question below gets the same treatment: why it exists, the failure it prevents, the code, and where "good enough" sits. I care about that last one. Over-engineering is a real mistake too, and if you're building a personal page with no logins and no money, most of this is more than you need.
Note: Checked on October 30, 2026 with Next.js 16.4, React 19, Zod 4.3, Stripe SDK 20.4, Vitest 3 and PostgreSQL (through PGlite). All TypeScript passed strict type-checking, and the test suite, the validation rules, the request ID code and the SQL were actually run. The Upstash limiter was type-checked against version 2.2 only, because it needs a real Redis to run. The Prisma and Next.js docs change quickly, so check the linked pages if you're reading this much later.
1. Every input is a lie until you've checked it
TypeScript gives you a comfortable feeling. You say quantity is a number, the compiler nods, everything looks safe. But types are erased before your code runs. At runtime, your server receives whatever bytes the network delivers, and the network hasn't read your interfaces.
A trust boundary is any point where data enters your code from outside it: forms, URL parameters, request bodies, webhooks, replies from other companies' APIs. Picture a bouncer at a club door. Once you're inside, nobody asks for your ID again, so the door is where the check has to happen. The rule is simple: outside data is hostile until a schema proves otherwise. A schema is just a written description of what valid data looks like.
Here's the classic failure. A beginner's checkout sends the price along with the order, because that's where the tutorial put it. Anyone can open the browser's developer tools and change that number. Stripe won't save you, because when you create a one-off price, the amount is what you tell it. Try it yourself below. Change the price, then send it to each server.
Live demo: Who decides the price?. It runs right in the page, so try it in the original article.
Both servers got the same request. The only difference is that one believed it. Here's the real version with Zod, the validation library most TypeScript projects use.
app/checkout/actions.ts
'use server'; import { z } from 'zod'; import { db } from '@/lib/db'; import { startCheckout } from '@/lib/stripe'; const checkoutInput = z.object({ productId: z.cuid(), quantity: z.int().min(1).max(10), }); export async function createCheckout(raw: unknown) { const parsed = checkoutInput.safeParse(raw); if (!parsed.success) { return { ok: false as const, error: 'Invalid input.' }; } const product = await db.product.findUnique({ where: { id: parsed.data.productId }, }); if (!product) { return { ok: false as const, error: 'Product not found.' }; } const url = await startCheckout(product.name, product.priceCents, parsed.data.quantity); return { ok: true as const, url }; } - 'use server' turns this function into a Server Action, which you can call from a form or button. Next.js is blunt about what that means: treat it as reachable by a direct POST request. It's a public door, even if no button on your site points at it.
- checkoutInput is the schema. z.cuid() accepts only IDs in the format Prisma generates with cuid(), so change this if your IDs look different. z.int().min(1).max(10) rejects 0, 2.5, and someone ordering a million. These top-level forms are current in Zod 4; the older z.string().email() style is deprecated.
- raw: unknown is on purpose. Typing it as a nice interface would be a lie, because you don't know yet. unknown forces you to check before you touch it.
- safeParse returns a result instead of throwing, so bad input is a normal outcome and not a crash. Remove this check and quantity: -5 goes straight to your payment code.
- The price comes from db.product, your own database. If the input carries an extra priceCents: 1, Zod drops it from the output, so a forged price never even reaches the next line.
- Failures come back as plain return values. Next.js recommends exactly that for expected errors in Server Actions, so the form can show the message.
From the docs: reachable by a direct POST request, Zod 4, Next.js recommends exactly that.
Webhooks deserve a special mention. A webhook is another service calling your server to tell you something happened, like "this payment succeeded". Anyone on the internet can send that same request to that same URL, so you verify the signature before believing a word of it. The Stripe webhook guide shows how, and the Zod guide goes deeper on schemas. If you've never compared browser checks and server checks side by side, the login form article has a live demo of both.
Good enough: a schema on every Server Action and route handler that accepts input, and one hard rule that prices, roles and other users' IDs never come from the client. Inside your own code, between your own functions, you don't need to re-validate. Check at the door.
2. Errors are part of the design
In production, errors aren't exceptional. They're weather. Databases pause, APIs time out, someone submits a form on a train going into a tunnel. How your app behaves in those moments is either something you decided or something that happens to you.
The worst version is silence, and it usually comes from an empty catch block: code that grabs an error and does nothing with it. Turn off the network in this demo and submit both forms.
Live demo: The form that lies. It runs right in the page, so try it in the original article.
Look at what the first form did. The request failed, and it thanked the user anyway. The user waits for emails that never come. You look at your subscriber count and decide your content isn't good enough. One empty catch lied to both of you. OWASP, the group that publishes the best-known list of web security risks, added Mishandling of Exceptional Conditions as a new category in its 2025 edition, so this isn't only about manners.
Warning: Every catch block must do one of three things: rethrow the error, log it and fall back on purpose, or show the user an honest message. A catch that does none of these isn't handling the error. It's hiding it.
For rendering failures, Next.js gives you error.tsx. Put one in a folder and it catches errors from that part of the page tree, so a broken dashboard doesn't take your whole site down.
app/dashboard/error.tsx
'use client'; export default function DashboardError({ error, retry, }: { error: Error & { digest?: string }; retry: () => void; }) { return ( <div role="alert"> <h2>We couldn't load your dashboard.</h2> <p>Your data is safe. Please try again.</p> {error.digest && <p>Reference for support: {error.digest}</p>} <button onClick={() => retry()}>Try again</button> </div> ); } - 'use client' is required, because error boundaries must be Client Components. A boundary has to keep working in the browser after something went wrong on the server.
- error.digest is a generated hash of the error. In production, errors from Server Components reach the browser with a generic message, so you don't leak internals. The digest lets you match this screen to the real error in your server logs.
- {error.digest && ...} is there because errors thrown in the browser don't get that server hash. Without the check you'd show "Reference for support:" followed by nothing.
- retry re-renders the failed part of the page. It became stable in Next.js 16.3, so older tutorials will show reset, and on those older versions that's what you use. If your types complain about retry, check your Next.js version first.
- Look at what the message tells the person: what failed, that their data is safe, what to do next. Compare that with "Oops, something went wrong".
From the docs: error boundaries must be Client Components.
Note: For advanced readers: error.tsx has blind spots. It doesn't wrap the layout in its own folder, so root layout errors need global-error.tsx. It doesn't catch errors in event handlers either; those you catch yourself and keep in state. And expected Server Action failures should be return values, not throws. If you're wondering where to put boundaries, the error boundaries guide linked below covers it.
Good enough: an error.tsx on every major section, zero empty catch blocks, and honest messages. The full reasoning on placement is in the error boundaries guide. You don't need a fancy error library. You need to stop lying when things break.
3. The day you get traffic is the day you get abuse
Once your app has a public URL, not all of your visitors are people. Scrapers copy your content. Scanners probe for known holes. Bots replay leaked password lists against anything shaped like a login form. None of it is personal. It's just cheap to do.
Picture a contact form that sends email. A bot finds it and submits it all night. Your sending quota is gone, and your real mail starts looking like spam. A login with no limit is even worse: it's a free password-testing service that you host for attackers. And if your hosting bills per use, an endpoint anyone can call in a loop is a bill with your card on file.
Rate limiting is the fix: you count requests per person and refuse the extras. It's the same as a ticket machine at the door that stops at a set number per person. The status code for refusing is 429, Too Many Requests. Click slowly in this demo, then press the bot button. The window here is 20 seconds so you don't have to sit around.
Live demo: A rate limiter you can annoy. It runs right in the page, so try it in the original article.
One trap before the code. You might write a counter in a normal variable, and it'll even pass your tests. In production, your app may run on many instances at once, and each one has its own memory, so each one has its own count. The counter has to live somewhere they all share. Redis, a very fast database for small pieces of data, is the usual choice, and Upstash offers it over plain HTTP, which suits serverless.
lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; export const loginLimiter = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(5, '1 m'), prefix: 'rl:login', }); - Redis.fromEnv() reads your connection details from environment variables, so no secrets sit in the code.
- slidingWindow(5, '1 m') means five requests per rolling minute. A fixed window resets on the clock, which lets someone send five at 12:00:59 and five more at 12:01:00. Sliding smooths that out. Upstash notes that it's an approximation and costs more to run than a fixed window, a trade I'd accept on a login.
- prefix keeps these counters apart from any other limiter you add later, like one for your contact form.
From the docs: Upstash's algorithm docs.
app/api/login/route.ts
import { ipAddress } from '@vercel/functions'; import { handleLogin } from '@/lib/login'; import { loginLimiter } from '@/lib/rate-limit'; export async function POST(request: Request) { const ip = ipAddress(request) ?? 'unknown'; const { success, reset } = await loginLimiter.limit(ip); if (!success) { const seconds = Math.max(1, Math.ceil((reset - Date.now()) / 1000)); return new Response('Too many attempts. Please wait a moment.', { status: 429, headers: { 'Retry-After': String(seconds) }, }); } return handleLogin(request); } - ipAddress(request) comes from @vercel/functions. On other hosts, read the IP the way your host documents, because headers a client can set are headers a client can fake.
- ?? 'unknown' handles the case with no IP, like local development. Everyone without an IP shares one bucket, which is fine locally.
- reset is a timestamp in milliseconds, while Retry-After is in seconds. Skip the conversion and a one-minute wait turns into more than 16 hours, so the math on that line matters. Math.max keeps it at one second or more.
- handleLogin only runs after the check. The limit has to sit in front of the expensive work, or you've already paid for the request you're refusing.
From the docs: @vercel/functions, timestamp in milliseconds, Retry-After is in seconds.
Note: For advanced readers: three failure modes. First, by default a limiter timeout lets the request through after 5 seconds, so if Redis is down, your limit is gone. That's a sensible choice for a blog comment and a debatable one for login, so decide it. Second, an IP limit doesn't stop credential stuffing from thousands of IPs, so add a second limiter keyed by the account being attacked, and be careful not to let an attacker lock real users out. Third, whole offices and schools can share one IP, so keep limits generous on normal pages.
Plan B if you don't want another service: Vercel has firewall rate limiting on all plans, with one rule and fixed windows on the free plan. That can cover your login route while you're small. More options, including what to count when IPs are shared, are in the rate limiting guide.
Good enough: strict limits on login, on anything that sends email, and on anything that costs you money per call. Everything else gets a generous default or nothing at all, so real people never notice it.
4. Logs you can actually search at 2 a.m.
In development, you are the monitoring. The console is open, the error shows up in red, you fix it. In production you're asleep, and your app volunteers nothing. Whatever you didn't deliberately record never happened.
The failure arrives by email: "I tried to buy your course yesterday. The money left my account but I never got access." No logs means no answer. You can't find the request, and you can't see which step failed. You're left apologizing and guessing, and that's the "my own bug hurts them" moment I never prepared for.
Think of a parcel with a tracking number. Every depot scans the same number, so when the parcel goes missing, you can see the last place it was. A request ID is that tracking number: one random ID, stamped on every log line that request produces. Structured logging means each line is data (usually JSON) instead of a sentence, so you can search it.
lib/log.ts
type Level = 'info' | 'warn' | 'error'; export function log( level: Level, msg: string, ctx: Record<string, unknown> = {}, ) { console[level]( JSON.stringify({ ...ctx, level, msg, ts: new Date().toISOString(), }), ); } - Level is a small union type, so a typo like 'eror' fails at compile time.
- ctx is whatever context you have: the request ID, a product ID. It defaults to an empty object so you can skip it.
- ...ctx comes first on purpose. Later keys win in JavaScript, so if someone passes a stray level in ctx, it can't overwrite the real level. It's easy to get this backwards.
- console[level] picks console.info, console.warn or console.error, and JSON.stringify makes it one searchable line. Your host collects anything written to the console.
Now the part that creates the tracking number. Next.js 16 renamed middleware to proxy, so older articles, including some of mine, still call this file middleware.ts. It's the same idea: code that runs before a request reaches your page.
proxy.ts
import { NextResponse, type NextRequest } from 'next/server'; export function proxy(request: NextRequest) { const requestId = crypto.randomUUID(); const headers = new Headers(request.headers); headers.set('x-request-id', requestId); const response = NextResponse.next({ request: { headers } }); response.headers.set('x-request-id', requestId); return response; } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], }; - crypto.randomUUID() makes the ID. We always create a new one, so a fake x-request-id sent by a client is gone by the time the request moves on. Otherwise an attacker could pollute your logs with IDs of their choosing.
- new Headers(request.headers) copies the incoming headers, because you can't simply edit them in place.
- NextResponse.next({ request: { headers } }) passes the new header on to your page. The nesting matters. The docs warn that NextResponse.next({ headers }) without request sends those headers to the client instead.
- response.headers.set puts the ID on the response too, so a support email can include it. This one is safe to expose, since it's just a random reference.
- matcher skips static files. Without a matcher, the proxy runs on every single request, including every image.
lib/report.ts
import { headers } from 'next/headers'; import { log } from '@/lib/log'; export async function reportCheckoutFailure(productId: string, error: unknown) { const requestId = (await headers()).get('x-request-id'); log('error', 'checkout failed', { requestId, productId, error: String(error) }); } That's how you read it back anywhere on the server. headers() is async in current Next.js, which is why there's an await. String(error) matters too: passing an Error object directly to JSON.stringify gives you {}, which is a very quiet way to lose the one thing you needed.
Warning: Never log passwords, tokens, full card data or session cookies. Be careful with emails and other personal details too. A log is a database that everyone forgets is a database. Log the ID of the user, not their life story.
Add error tracking on top. Sentry has a free tier and will alert you when something new starts failing, and the observability guide shows a full solo setup. The logging details are in the structured logging guide.
Good enough: error tracking for crashes, and request IDs in logs on the paths that handle money, logins and webhooks. You don't need distributed tracing for an app with one service in it. Here's your test: a user reports a problem from yesterday. Can you find out what happened? If yes, you have enough.
5. Tests on the paths that move money, not everywhere
A test is a small program that runs your code and checks the answer. Think of it as a signed statement: this worked on this date, and I want to hear about it the moment it stops working. That matters, because the person most likely to break your checkout is you, three months from now, cleaning something up with total confidence.
Testing everything is a waste for a solo developer, and you'd spend weeks on code a glance could verify. Testing nothing is worse. So test the paths where a bug costs money, access or trust: login, payment, permissions, personal data. Here's a pricing function, and it has two ideas in it worth stealing.
lib/pricing.ts
const CODES: Record<string, { percentOff: number; expires: string }> = { LAUNCH50: { percentOff: 50, expires: '2099-01-01' }, OLD20: { percentOff: 20, expires: '2024-12-01' }, }; export function applyDiscount(cents: number, code: string, now = new Date()) { const discount = CODES[code]; if (!discount) throw new Error('Unknown code'); if (now >= new Date(discount.expires)) throw new Error('Expired code'); return Math.round(cents * (1 - discount.percentOff / 100)); } - Everything is in cents, as a whole number. Never keep money in decimals. In JavaScript, 0.1 + 0.2 gives 0.30000000000000004, and those tiny errors add up in exactly the place you don't want them. Stripe's API works in the smallest currency unit too.
- now = new Date() is a default parameter. In real use you never pass it. In a test you pass a fixed date, so the test gives the same answer forever. A test that reads the real clock passes today and fails on some random Tuesday years from now.
- Math.round keeps the result in whole cents. Half of 999 is 499.5, and there's no such thing as half a cent.
- Throwing on an unknown or expired code means a bad code can never quietly become a full-price or free purchase. The caller has to deal with it.
lib/pricing.test.ts
import { describe, expect, it } from 'vitest'; import { applyDiscount } from './pricing'; describe('applyDiscount', () => { it('takes the percentage off once', () => { expect(applyDiscount(5000, 'LAUNCH50')).toBe(2500); }); it('always returns whole cents', () => { expect(applyDiscount(999, 'LAUNCH50')).toBe(500); }); it('rejects a code nobody created', () => { expect(() => applyDiscount(5000, 'FREESTUFF'Source:https://dev.to/codefromscratch/production-grade-code-what-separates-hobby-apps-from-real-ones-eofRelated posts
One Profile Field, Four Length Limits. My AI Matcher Reads the Smallest.
Chrome DevTools MCP: Empowering AI Agents with Browser Control
Recortar un MP4 sin dejar los subtítulos en el tiempo del original
I shipped a VPN app on iOS, Mac, Android and Windows without writing a VPN protocol
