Skip to content
Production-Grade Code: What Separates Hobby Apps from Real Ones
Milos KnezevicMilos Knezevic20 Oct 2026 · 17 min read

Production-Grade Code: What Separates Hobby Apps from Real Ones

The gap between a demo and a production ready Next.js app is eight learnable disciplines. Here is the full list, each with the failure story it prevents.

The gap is a list, not a talent

Every tutorial ends the same way. The form submits, the data appears, the presenter says "and that's it" — and the recording stops at the exact moment real software development begins. What happens when the input is malformed? When the database is asleep and the homepage build needs it? The tutorial does not say, because on localhost, with one well-behaved user, none of that ever happens.

This is why "it works" and "it's done" are different sentences. "It works" means the happy path executes when a friendly person follows it. "It's done" means the app behaves deliberately when an unfriendly person, an unreliable network, or your own future refactor does something the happy path never anticipated. The second sentence is the one you can charge money for.

The good news: production-grade is not a mystical quality you absorb after a decade in industry. It is eight disciplines, each with a concrete failure it prevents and a minimal implementation that fits a solo developer's time and attention. For each one I will give you the why, the failure story, and the bar that is genuinely good enough. Not the enterprise version. Over-engineering is just under-engineering wearing a suit.

1. Validation at trust boundaries: every input is hostile until proven otherwise

TypeScript creates a comfortable illusion. You declare that email is a string and quantity is a number, the compiler agrees, and everything feels safe. But types are erased before your code ever runs, and at runtime your deployed JavaScript receives whatever bytes the network delivers. The network has not read your interfaces.

A trust boundary is any point where data enters your process from outside it: form submissions, route parameters, query strings, request bodies, webhooks, responses from third-party APIs. The rule is short and absolute: data crossing a trust boundary is hostile until a schema proves otherwise.

Here is the failure this prevents. A hobby checkout reads the price from the request body, because that is where the tutorial put it. A curious user opens devtools, edits the request, and buys your 49-euro product for one cent — Stripe processes it without complaint, because Stripe trusts the amount you give it. The same root cause hides everywhere: the 80,000-character bio, the registration payload with an extra role field set to admin that your ORM happily persists.

The minimal production-grade implementation is a schema library at every boundary, and in the TypeScript world that means Zod: define the shape once, parse the raw input, and derive the static type from the schema so validation and types can never drift apart. I walk through the full pattern in the guide to Zod at trust boundaries. The shape of it is always the same:

app/checkout/actions.ts
'use server';

import { z } from 'zod';
import { db } from '@/lib/db';
import { startStripeSession } from '@/lib/stripe';

const checkoutInput = z.object({
  productId: z.string().cuid(),
  quantity: z.number().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.' };
  }
  // The price comes from the database. Never from the client.
  const product = await db.product.findUniqueOrThrow({
    where: { id: parsed.data.productId },
  });
  return startStripeSession(product.priceCents, parsed.data.quantity);
}

Notice the parameter type: unknown, not a trusting interface. Webhooks deserve a special mention, because they are the trust boundary even careful developers forget. A request to your Stripe webhook endpoint claims to come from Stripe, but anyone on the internet can send a POST to that URL claiming a payment succeeded. Verifying the signature before believing a word of the payload is its own discipline, and the Stripe webhook verification guide covers how to do it without locking yourself out of local testing.

The good-enough bar: a Zod schema on every server action and route handler that accepts input, and one hard rule — prices, roles, and other users' IDs never arrive from the client. Validate boundaries only; internal calls between your own modules can trust each other.

2. Error handling is design: errors are part of your contract

In production, errors are not exceptional. They are weather. Databases pause, third-party APIs time out, users submit forms from a train entering a tunnel. How a function fails is as much a part of your API contract as its return type. The only question is whether what happens next was designed or improvised.

Hobby apps have two failure modes: a white screen, or — worse — silence. The silent one comes from the most dangerous four characters in JavaScript: an empty catch block. A newsletter form whose request fails inside a swallowed catch tells the user they subscribed and tells you nothing at all. The user waits for emails that never come; you conclude your content is the problem. The empty catch lied to both of you.

warning

An empty catch block is the only place in a codebase where a bug deletes its own evidence. Every catch you write must do one of three things: rethrow, log and degrade deliberately, or show the user an honest message. A catch that does none of these is not error handling — it is error hiding.

In the App Router, the designed-behavior tool is the error.tsx file: each route segment declares what its users see when rendering fails, scoped so a broken dashboard widget does not take down the page around it. Where to place these boundaries and why one global boundary is almost as bad as none is the subject of the error boundaries guide. The minimal version looks like this:

app/dashboard/error.tsx
'use client';

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div role="alert">
      <h2>Something went wrong loading your dashboard.</h2>
      <p>Your data is safe. Error reference: {error.digest}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Note what the fallback says: what broke, that the user's data survived, a reference code support can act on, and a way to retry. Compare that with "Oops, something went wrong :(".

The good-enough bar: an error.tsx on every major route segment, zero empty catch blocks anywhere, server errors logged with enough context to investigate, and honest user-facing messages. You do not need a Result type library or railway-oriented programming. You need to stop lying when things break.

3. Rate limiting: the day you get traffic is the day you get abuse

The moment your app has a public URL, most of its traffic is not human. Scrapers harvest your content, vulnerability scanners probe a Next.js site for WordPress login pages, and credential-stuffing bots replay leaked password lists against anything that looks like a login form. None of it is personal — until it is; the internet also contains grudges, and an unprotected endpoint is an invitation.

The failure stories are concrete. A contact form that sends email through Resend gets discovered by a bot, which submits it ten thousand times overnight: your sending quota is gone and real mail lands in spam for weeks. A login endpoint without limits is a free password-testing service you host for attackers. And on serverless you pay per invocation, so an endpoint anyone can call in a loop is a billing attack with your card on file.

The implementation needs one piece of honesty about the platform: on Vercel, an in-memory counter dies with the serverless instance that holds it, so limits must live in shared storage. The standard answer is a sliding-window limiter backed by Upstash Redis — a few lines to set up, free at solo-product volume. The full treatment, including what to limit by when IPs are shared, is in the rate limiting guide for Next.js.

lib/rate-limit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

export const authLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 m'),
  prefix: 'rl:auth',
});

// In a route handler:
//   const { success } = await authLimiter.limit(ip);
//   if (!success) {
//     return new Response('Too many requests', {
//       status: 429,
//       headers: { 'Retry-After': '60' },
//     });
//   }

The good-enough bar: strict limits on the expensive and abusable paths — login attempts in single digits per minute, anything that sends email or calls a paid API limited to a handful per hour per identifier — and a generous default everywhere else so real users never notice. Return 429 with a Retry-After header so well-behaved clients back off.

4. Logging you can search at 2 a.m.

In development, you are the observability. The console is open, the error appears in red, you fix it. In production you are asleep, and a deployed app volunteers exactly zero information about its failures. Whatever you do not deliberately record never happened. A scattering of console.log statements does not change this — unstructured text in a log stream is something you scroll, not something you search.

The failure scenario arrives by email: "I tried to buy the course yesterday. The money left my account but I never got access." If you have no logs, you now have nothing — no way to find the request, no way to see which step failed. With logs that carry a request ID through every step, the same investigation takes minutes: find the checkout request, follow its ID through the payment call and the database write, see exactly which line gave up.

Observability for a solo app has two halves. Error tracking — Sentry's free tier is the usual choice — catches unhandled exceptions and alerts you when something new starts failing. Structured logging tells the story of each request on purpose: JSON lines, one event per line, every line carrying the request ID that lets you reconstruct the path. The pattern of generating that ID in middleware and threading it through your app is the core of the structured logging guide, and it is less code than you expect:

lib/log.ts
type Level = 'info' | 'warn' | 'error';

export function log(
  level: Level,
  msg: string,
  ctx: Record<string, unknown> = {}
) {
  console[level](
    JSON.stringify({
      level,
      msg,
      ts: new Date().toISOString(),
      ...ctx, // always include requestId where you have one
    })
  );
}

The good-enough bar: Sentry wired up for unhandled errors, and structured logs with request IDs on the money paths — checkout, auth, webhooks. You do not need distributed tracing for an app with one service in it. The test is a single question: when a user reports a problem from yesterday, can you find out what happened in under ten minutes? If yes, you have enough. If no, nothing else on this list will save you, because you will not know which item failed.

5. Tests on the money paths, not coverage theater

Full test coverage is enterprise theater for a solo developer — you will spend weeks testing code that a glance verifies. But zero tests is worse, and the realistic case for testing has nothing to do with coverage percentages. A test is a regression contract: a signed statement that this behavior worked on this date, and the suite will scream if it ever stops. That matters because the person most likely to break your checkout is you, three months from now, refactoring with confidence.

The failure it prevents: you clean up the pricing utility on a Friday, a discount now applies twice under a condition you did not retest by hand, and nobody notices until you reconcile the month and find you sold forty licences at half price. Or an auth change silently locks out everyone who registered with OAuth. Both bugs are trivial to catch with a test and expensive to catch with customers.

lib/pricing.test.ts
import { describe, expect, it } from 'vitest';
import { applyDiscount } from './pricing';

describe('applyDiscount', () => {
  it('applies a percentage code exactly once', () => {
    expect(applyDiscount(5000, 'LAUNCH50')).toBe(2500);
  });

  it('never produces a negative price', () => {
    expect(applyDiscount(100, 'LAUNCH50')).toBeGreaterThanOrEqual(0);
  });

  it('rejects expired codes', () => {
    expect(() => applyDiscount(5000, 'BLACKFRIDAY2024')).toThrow();
  });
});

What counts as a money path: authentication, checkout and webhook handling, permission checks, and anything that touches personal data. Test the logic as units and the flows as integration tests. Add one rule that compounds over time: every bug you fix gets a test that would have caught it, so no bug gets to embarrass you twice. The full picture of which kind of test belongs where is in the complete React testing strategy.

The good-enough bar: a few dozen tests that pin down pricing, permissions, and checkout beat five hundred snapshot tests of components that change weekly. If a path moves money, grants access, or touches data you would have to apologize for losing — it gets a test. Everything else can wait until it breaks once.

6. Security hygiene: the boring 80 percent

Security has a glamorous 20 percent — the clever exploits that make conference talks — and a boring 80 percent that actually determines whether your app gets owned. The boring part is hygiene: where secrets live, how sessions are stored, which headers your responses carry, whether the delete handler re-checks the session. None of it is intellectually hard. All of it has to be done every time, which is exactly why it usually is not.

Start with secrets, because a leaked secret has no upper bound on cost. A live Stripe key lets a stranger create charges in your name; a production database URL is the entirety of your users' data. Bots continuously scan public GitHub activity for key-shaped strings, so a credential pushed to a public repository should be assumed harvested within minutes — and force-pushing the commit away does not unleak it. The only fix is rotating every key it contained.

  • .env goes into .gitignore before the first commit, not after the first scare; .env.example carries placeholders, never values
  • Separate keys per environment: Stripe test keys in development, live keys exist only in the host's environment settings
  • Anything prefixed NEXT_PUBLIC_ is compiled into the browser bundle — by definition it is not a secret
  • Session cookies are httpOnly, Secure, and SameSite, so scripts cannot read them and other sites cannot ride them
  • Every protected server action and route handler re-checks the session and scopes queries to the owner — hiding a button is decoration, not security
  • Passwords are hashed with bcrypt at cost 12 or better (or Argon2id), never anything you invented
  • Responses carry the boring headers: HSTS, X-Content-Type-Options, frame-ancestors — one config block, set once

One habit converts a whole class of midnight incidents into build failures you fix at your desk: validate configuration at boot, so a missing variable fails the deploy instead of a user's request.

lib/env.ts
import { z } from 'zod';

const schema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
  RESEND_API_KEY: z.string().min(1),
});

// Import env everywhere instead of process.env.
// A missing variable now fails the deploy, not a user's request.
export const env = schema.parse(process.env);

tip

If you ever even suspect a key has leaked, rotate it immediately and move on. Rotation costs five minutes; deciding whether you really need to rotate costs a weekend of doubt. There is no prize for keeping a key that might be compromised.

7. Migrations: schema changes that do not lose data

On localhost, the database is disposable. You change the schema, reset, reseed, move on. The day real users exist, the database becomes the one part of your system you cannot redeploy your way out of — code is replaceable, data is not — and schema changes graduate from a keystroke to a discipline.

The classic failure looks innocent. You rename a column from name to fullName, your ORM diffs the schema, and the generated migration is not a rename — it is a drop followed by an add. Run that against production and every value in the column is gone. The second failure is subtler: for a window during every deploy, old code is still serving requests against the new schema. Drop a column in the same release as the code that stops using it, and that window is full of crashes.

The discipline that prevents both is called expand and contract, and it turns one risky change into three safe ones:

  • Expand: add the new column or table without touching the old one — additive changes are always safe to deploy
  • Migrate: ship code that writes to both and reads from the new, then backfill old rows with a one-off script
  • Contract: only when nothing reads the old column — verified, not assumed — ship the migration that drops it

It feels slow the first time. It is much faster than restoring a backup while your users watch, which is the alternative. One habit completes the discipline: read every generated migration before it runs, because the ORM diffs structure, not intent — it cannot tell a rename from a drop-and-add.

The good-enough bar: migrations live in git and run as part of the deploy, never typed against production by hand; destructive changes follow expand and contract; and you have restored a backup at least once, on purpose, so the first time is not during an incident.

8. Graceful degradation: the database being down is not a 500

Every external dependency — database, payment provider, email service — will be unavailable at some point, and each should have a decided failure mode. The hobby default is that any dependency failing takes the whole page with it: the database hiccups, the homepage throws, and a visitor who only wanted to read your writing meets a 500. Your most fragile dependency sets the availability of your most public page.

This site is the worked example. The database behind it runs on a free tier that pauses itself after a stretch of inactivity — which is fine, because the public site never finds out. Every public page builds and renders from the content store with zero database queries; the parts that genuinely need Postgres, like the admin area, render per request and fail alone. The database can sleep for a week and the article you are reading right now does not notice. That was not an accident of architecture — it was the requirement the architecture was chosen to satisfy.

You will not always be able to make a page fully independent of a dependency, and that is where degradation becomes a decision you write down in code. The rule from the error-handling section — no silent catches — has a precise exception: a catch that encodes a real product decision about what happens when a dependency is gone, visible and logged:

lib/catalogue.ts
import { db } from '@/lib/db';
import { log } from '@/lib/log';

export async function getPublishedEbooks() {
  try {
    return await db.ebook.findMany({ where: { published: true } });
  } catch (error) {
    // Decided degradation: the store shows an empty shelf,
    // the page around it stays up, and we know it happened.
    log('error', 'ebook catalogue unavailable', { error: String(error) });
    return [];
  }
}

Ask it for each dependency in turn. Database down: which pages still render, and what do the rest show? Email provider down: does signup fail, or does the welcome email queue and retry? Payment provider down: does the whole site break, or just the checkout button? You need to have answered these before production asks.

The good-enough bar: public marketing and content pages render with the database unreachable; every deliberate fallback logs the failure it is absorbing; and no third-party outage can take down a page that does not actually need that third party.

The checklist

Here is the whole article as the review checklist I would run against your app before launch. Score each line as handled deliberately, handled by accident, or not handled — only the first counts.

  • Every server action and route handler parses its input with a schema; prices, roles, and other users' IDs never arrive from the client
  • Webhook signatures are verified before the payload is believed
  • Zero empty catch blocks; every major route segment has an error.tsx that says what broke and what to do
  • Strict rate limits on auth, email, and paid-API endpoints, backed by shared storage, returning 429 with Retry-After
  • Structured JSON logs with request IDs on checkout, auth, and webhooks; error tracking alerts you to new failures
  • Tests pin down pricing, permissions, and the checkout flow; every fixed bug carries a regression test
  • .env has never touched git; config is validated at boot; live keys exist only in the host's environment
  • Sessions are httpOnly, Secure, SameSite; every protected handler re-checks authorization server-side
  • Migrations are read before they run; destructive changes ship expand–contract; a backup restore has been rehearsed
  • Public pages render with the database down; every external dependency has a decided, logged failure mode

Most apps score two or three out of ten on first contact with this list. That is not a verdict on you; it is a verdict on what you were taught. Unlike talent, this gap closes in weeks.

The learning path: from list to habit

Read back through the eight and notice what they have in common: none are visible in a demo — each is invisible exactly until the moment it is the only thing that matters. That is why tutorials skip them, and why "it works" is the cheapest sentence in software while "it's done" has to be earned.

This article is the map; five of the disciplines have full guides that go to the depth a checklist cannot, and they are designed to be worked in order. Start with validation with Zod at trust boundaries, because it removes the largest class of bugs for the least effort, and the schemas you write there reappear in everything that follows. Then make failure a designed state with error boundaries done right, so that everything you build afterwards breaks politely instead of silently.

Before anything public launches, put rate limiting on your API routes — it is the one item you cannot retrofit after the bots have already found you. The week you launch, add structured logging with request IDs, because that is the week strangers start doing things you did not predict. And the day money enters the picture, verifying Stripe webhooks stops being recommended and becomes mandatory, alongside a real testing strategy so your own refactors cannot quietly break the path that pays you.

Then do the honest exercise. Take the app you have right now and walk the checklist above, one discipline per week if that is what your schedule allows. When the list is green, you will own something rarer than a finished app: a finished app you can defend. What comes after that — domains, deploys, environment promotion, the launch itself — is its own set of disciplines, and the guide to shipping a Next.js app to production picks up exactly where this one ends.