DEV Community

Cover image for Production-Grade Code: What Separates Hobby Apps from Real Ones
CodeFromScratch
CodeFromScratch

Posted on Originally published at codefromscratch.org

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

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 };
}
Enter fullscreen mode Exit fullscreen mode
  • '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&apos;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>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • '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',
});
Enter fullscreen mode Exit fullscreen mode
  • 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);
}
Enter fullscreen mode Exit fullscreen mode
  • 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(),
    }),
  );
}
Enter fullscreen mode Exit fullscreen mode
  • 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).*)'],
};
Enter fullscreen mode Exit fullscreen mode
  • 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) });
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode
  • 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')).toThrow('Unknown code');
  });

  it('rejects an expired code', () => {
    const afterExpiry = new Date('2025-01-01');
    expect(() => applyDiscount(5000, 'OLD20', afterExpiry)).toThrow('Expired code');
  });
});
Enter fullscreen mode Exit fullscreen mode
  • describe groups related tests, and each it() is one test case. Each name says what should be true, so a failure reads like a sentence.
  • toBe(2500) checks the exact number, including that the discount only applied once.
  • The 999 case is the rounding edge, the kind of value that causes real bugs.
  • The throw tests wrap the call in an arrow function, () => ..., so Vitest can run it and catch the error itself. Without the wrapper, the error would blow up before expect ever saw it.
  • afterExpiry is that fixed date in action. Run this file with Vitest and all four tests pass.

One rule makes this compound: every bug you fix gets a test that would have caught it. No bug gets to embarrass you twice. The React testing strategy covers which kind of test goes where.

Good enough: tests that pin down pricing, permissions and checkout. That beats a mountain of snapshot tests on buttons that change every week. If a path moves money, grants access, or touches data you'd have to apologize for losing, it gets a test. The rest can wait until it breaks once.

6. Security is mostly boring, and that's why people skip it

Security has a glamorous part, the clever exploits people give talks about. And it has a boring part that decides most of what actually goes wrong: where secrets live, who's allowed to do what, which settings are on. None of it is hard. It just has to be done every time.

The most common hole: checking who, but not whose

Broken access control is number one on the OWASP Top 10 2025. In plain words: a logged-in user can touch something that isn't theirs, often just by changing an ID. Like a hotel where your key card opens every room because the door only checks that it's a real card.

app/posts/actions.ts

'use server';

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

export async function deletePost(rawId: unknown) {
  const session = await auth();
  if (!session) return { ok: false as const, error: 'Please sign in.' };

  const id = z.cuid().safeParse(rawId);
  if (!id.success) return { ok: false as const, error: 'Invalid post.' };

  const { count } = await db.post.deleteMany({
    where: { id: id.data, authorId: session.user.id },
  });
  if (count === 0) return { ok: false as const, error: 'Post not found.' };

  return { ok: true as const };
}
Enter fullscreen mode Exit fullscreen mode
  • auth() checks that someone is logged in, inside the action itself. A check on the page doesn't protect the actions on it, because the action can be called directly.
  • The ID goes through a schema, same rule as before. It comes from the client, so it's hostile.
  • This is the line that matters: where has both id and authorId. If the post belongs to someone else, nothing matches and nothing is deleted. Remove authorId and any logged-in user can delete any post by trying IDs.
  • deleteMany instead of delete is deliberate. It returns a count rather than throwing when nothing matches, and it lets you filter on the owner directly.
  • The same error comes back for "doesn't exist" and "isn't yours", so an attacker can't use it to discover which IDs are real.

From the docs: A check on the page doesn't protect the actions on it.

Hiding a button isn't security. It's decoration. The server checks every time.

Secrets: assume a leak is permanent

A secret is any key that proves you're you to another service: your Stripe key, your database URL. There's no upper limit on what a leak can cost. If you push one to a public repository, deleting the commit doesn't fix it. GitHub's own advice starts with revoking or rotating the secret, because copies may exist elsewhere. Rotating means replacing the key with a new one and killing the old one. So if you even suspect a leak, rotate first and think later.

lib/env.ts

import { z } from 'zod';

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

const result = schema.safeParse(process.env);
if (!result.success) {
  throw new Error(`Bad environment:\n${z.prettifyError(result.error)}`);
}

export const env = result.data;
Enter fullscreen mode Exit fullscreen mode
  • This schema treats your configuration as outside data, because it is. Your hosting dashboard is a trust boundary too, and a typo there is just as real.
  • startsWith('sk_') catches a classic mistake: pasting your public key where the secret one should go. If you use Stripe's restricted keys, they start with rk_, so adjust this.
  • z.prettifyError builds a readable list of what's wrong. It names the variable and the problem, and it doesn't print the value, which is exactly what you want in a build log.
  • export const env gives the rest of your app typed values. Import env everywhere instead of touching process.env, and the compiler knows these strings exist.

On its own, that file only fails when something first imports it, and that might be a user's request. So make the build load it, right here, along with your security headers.

next.config.ts

import type { NextConfig } from 'next';
import './lib/env';

const config: NextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
        ],
      },
    ];
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode
  • import './lib/env' runs the check while the build loads this config. With the variables missing, next build stops right away and lists all four; with valid values it finishes normally. That's a bad deploy caught at your desk instead of by a customer.
  • Strict-Transport-Security tells browsers to use HTTPS only, for two years here. Be careful: includeSubDomains covers every subdomain, so make sure they all have HTTPS first. This setting is hard to take back, because browsers remember it.
  • X-Content-Type-Options: nosniff stops browsers from guessing file types, which closes a trick where an uploaded file gets run as a script.
  • frame-ancestors 'none' stops other sites from embedding yours in an invisible frame to trick people into clicking things. Remove it if you actually need to be embedded.
  • Referrer-Policy limits how much of your URLs is passed along to other sites.

Tip: Plan B: if your build server doesn't have your production secrets, as in some Docker setups, this import will fail every build. In that case, remove it from next.config.ts and import lib/env.ts in your server code instead, so the check runs when the server first needs it. A full Content Security Policy is a bigger project than the one line here, so read the Next.js guide before you start.

The rest of the boring list:

  • .env goes into .gitignore before your first commit, and .env.example holds placeholders only. Turn on GitHub's push protection too, which blocks recognized secrets before they reach the repository.
  • Use separate keys per environment: Stripe test keys on your laptop, live keys only in your host's settings.
  • Anything starting with NEXT_PUBLIC_ is copied into the JavaScript sent to the browser. By definition, it isn't a secret.
  • Session cookies are HttpOnly, Secure and SameSite: JavaScript can't read them, they only travel over HTTPS, and other sites can't easily send them along.
  • Passwords are hashed, and you never invent your own scheme. OWASP's first choice is Argon2id; bcrypt is meant for older systems, with a work factor of at least 10 and a 72-byte input limit. Better yet, use an auth library that handles this, plus resets and sessions, for you.

From the docs: GitHub's push protection, copied into the JavaScript sent to the browser, HttpOnly, Secure and SameSite, OWASP's first choice is Argon2id.

That password reset question I never asked myself? A good auth library answers most of it. This is why I say you don't need to invent it. You need to know that it has to exist.

7. Your code can be replaced. Your data can't.

On your laptop, the database is disposable. Change the schema, wipe it, fill it back up. The day real users exist, that's over. If you deploy bad code, you redeploy old code. If you delete data, it's gone unless you have a backup that works.

A migration is a small script that changes your database's structure, kept in git along with your code. Here's the classic trap. You rename a column from name to fullName, and you assume the tool will rename it. It can't read your mind. It sees one column disappear and another appear, and Prisma's docs say so plainly: by default a rename creates a new column and drops the old one, along with its data. You get something like this:

migration.sql

ALTER TABLE "User" DROP COLUMN "name",
ADD COLUMN "fullName" TEXT NOT NULL;
Enter fullscreen mode Exit fullscreen mode

Run that on a Postgres table with two users and, because of NOT NULL, it fails and nothing changes: "column fullName of relation User contains null values". That's the lucky version. Make the new column optional and it runs with no complaint, and both users now have a fullName of null. Their names are gone. Same harmless-looking change, and the second version you wouldn't notice until someone complained.

For a simple rename you can edit the file. Run prisma migrate dev --create-only, which writes the migration without applying it, and replace those lines with a RENAME COLUMN. But a straight rename still has a problem for a live app: during a deploy, the old code and the new code run at the same time for a moment. The old code asks for name, the column is gone, and you get errors. That's why careful teams use expand and contract, which turns one risky change into three safe ones.

expand-and-contract.sql

-- Step 1, expand: add the new column. Old code keeps working.
ALTER TABLE "User" ADD COLUMN "fullName" TEXT;

-- Step 2, migrate: new code writes both columns. Then copy old rows.
UPDATE "User" SET "fullName" = "name" WHERE "fullName" IS NULL;

-- Step 3, contract: only when nothing reads "name" anymore.
ALTER TABLE "User" ALTER COLUMN "fullName" SET NOT NULL;
ALTER TABLE "User" DROP COLUMN "name";
Enter fullscreen mode Exit fullscreen mode
  • Step 1 adds the column as optional, so it can't fail on existing rows, and old code simply ignores it. Deploy this alone.
  • Next you deploy code that writes to both columns and reads from the new one. This part isn't in the SQL, and it's the part people forget.
  • Step 2 copies the old values. The WHERE means it only touches rows that still need it, so you can safely run it again if it stops halfway.
  • Step 3 happens in a later deploy, after you've verified nothing reads the old column. SET NOT NULL is the rule you wanted all along, added once the data can satisfy it. Then, and only then, the drop.

Run all three steps on the same two users, and both names end up in fullName with nothing lost. It feels slow the first time. It's much faster than restoring a backup while your users watch. It's like moving house: you bring your things to the new place first, and you hand back the old keys only when the old place is empty.

Note: For advanced readers: on a big table, the danger shifts from data to locks. ALTER TABLE takes an ACCESS EXCLUSIVE lock by default, and a normal CREATE INDEX blocks writes until it's done, so use CREATE INDEX CONCURRENTLY for live tables, which can't run inside a transaction. Adding a column with a non-volatile default doesn't rewrite the table in current PostgreSQL, while a volatile default does. And a backfill on millions of rows should run in small batches.

The commands here are for Prisma 7, where prisma migrate deploy applies migrations in production. Prisma 8 is at release candidate as I write this and its guide uses a different command, so match the docs to your version. The basics are in the Prisma guide.

Good enough: migrations live in git and run as part of your deploy, never typed by hand against production. You read every generated migration before it runs, because the tool compares structure, not intent. And you've restored a backup at least once, on purpose, so that the first time isn't during an emergency. Your Postgres provider decides what backups you get, so check that before you need them.

8. The database being down shouldn't mean a blank page

Every service you depend on will be unavailable at some point. The hobby default is that any failure takes the whole page down: the database hiccups, and someone who only wanted to read your About page sees an error. Your weakest dependency has set the uptime of your entire site.

A shop whose card reader dies doesn't lock its doors. It puts up a sign and takes cash. Graceful degradation is the same idea: when one part fails, the rest keeps working, and you decide in advance what the fallback is.

lib/catalog.ts

import { db } from '@/lib/db';
import { log } from '@/lib/log';

export async function getFeaturedProducts() {
  try {
    return await db.product.findMany({ where: { published: true } });
  } catch (error) {
    log('error', 'featured products unavailable', { error: String(error) });
    return [];
  }
}
Enter fullscreen mode Exit fullscreen mode
  • This looks like what I told you not to do in section 2, so what's different? It's a decision, and it isn't silent. The fallback is chosen on purpose and the failure gets logged. That's the "log and fall back on purpose" option.
  • return [] means the page renders with an empty shelf. Your page has to handle that well, with a message like "Featured products are unavailable right now", not a blank gap.
  • log('error', ...) means you find out. Without it, you've built the lying form from section 2 with extra steps.
  • await in return await matters. Without it, the promise is returned before it fails, and this catch never runs.

Warning: For advanced readers, three traps. One: on a statically cached page, that empty fallback can get cached too, so a brief database blip turns into an empty shelf until the next revalidation. Don't let pages that matter cache a failure. Two: a sleeping database often doesn't fail, it hangs, so without a timeout, your fallback never fires. Three: Next.js uses thrown errors internally for redirect() and notFound(), and those must not be swallowed by your catch. Keep those calls outside the try, or call unstable_rethrow(error) first in the catch.

So ask it for each dependency, one by one. Database down: which pages still render, and what do the rest show? Email provider down: does signup fail, or does the welcome email wait and retry? Payment provider down: does the site break, or only the checkout button? If you don't answer these now, production answers them for you.

Good enough: public content pages survive a database outage, every fallback logs what it's absorbing, and no outage can take down a page that doesn't actually need that service. And when a deploy itself is what broke, you want a rollback plan you've thought about before the bad day.

So that's all it is

Read back through the eight sections and notice what they share. None of this shows up in a demo. Every item is invisible right until it's the only thing that matters. That's why tutorials skip it, and it's why "it works" is the cheapest sentence in software.

But look at the code we wrote. A schema. A try/catch that tells the truth. A counter. A random ID. A few tests. One extra condition on a query. A column added before another gets dropped. None of that is genius, and there's no secret senior knowledge in it. Production-grade is just the happy path plus honest answers to eight questions. You've written harder code than this already.

What to be careful about, and the Plan B

The biggest mistake with a list like this is treating it as all or nothing. It can stop you from shipping at all. So here's how to size it.

  • Personal site, no logins, no money: keep your secrets out of git and validate your forms. Honestly, the rest is mostly overkill. Ship it.
  • Real users with accounts: everything in sections 1, 2, 3 and 6 before you launch. Add logging the week you launch.
  • Money involved: all eight, plus tests on checkout and verified webhooks.
  • Short on time before a launch: do validation and authorization first, since those are the holes attackers can use today. Logging is next, because it's how you'll learn about everything else.

And if you don't understand a piece of this, don't paste it and hope. If your Plan B is hope, that's not a Plan B. Ask what the line does, change it, break it on purpose, and watch what happens. I learned by trying things and by making mistakes, and a mistake on a test project is free.

The checklist

Here's the whole article as a review you can run on your app. Mark each line as handled on purpose, handled by accident, or not handled. Only the first one counts.

  • Every Server Action and route handler parses its input with a schema
  • Prices, roles and other users' IDs never come from the client
  • Webhook signatures are verified before the payload is believed
  • Zero empty catch blocks, and an error.tsx on every major section
  • Login, email and paid endpoints are rate limited in shared storage and return 429 with Retry-After
  • Logs are structured, carry a request ID, and hold no secrets
  • Error tracking tells you about new failures before users do
  • Tests cover pricing, permissions and checkout, and every fixed bug gets a test
  • Every query on private data is scoped to its owner on the server
  • .env has never touched git, config is validated at build time, and live keys exist only on your host
  • Security headers are set, and session cookies are HttpOnly, Secure and SameSite
  • Migrations are read before they run, and destructive changes use expand and contract
  • You've restored a backup at least once, on purpose
  • Public pages render with the database down, and each dependency has a decided failure mode

If you score low, that's not a verdict on you. It's a verdict on what you were taught.

What to learn next

This article is the map, and most sections have a guide that goes deeper. Here's the order I'd take them in. If the bigger picture of how these tools fit together is still fuzzy, read the full-stack walkthrough first.

Start with Zod at trust boundaries, because it removes the biggest class of bugs for the least effort. Then make failure a designed state with error boundaries. Before anything goes public, add rate limiting, since bots won't wait for you to be ready. The week you launch, add structured logging. The day money enters the picture, Stripe webhook verification stops being optional, and the Stripe setup guide covers the rest. When all of that is done, the production launch playbook picks up where this article ends: deploys, DNS, monitoring and launch day.

Now the honest exercise. Take the app you have right now and walk the checklist. One section a week is plenty, and a little most days beats a heroic weekend followed by a month off. Take a day or two off when it isn't going in, just don't let the breaks get long. 1% a day adds up to more than you'd think, and you don't have to know all of this yet. Plenty of people working in companies don't know everything either, and they ask for help.

You can do this, and you have to. If you weren't capable, I wouldn't spend my time on you. Just don't give up, and you'll always have me on your side.


This article first appeared on CodeFromScratch, where the live demos run right in the page. If you want the next one in your inbox, join the newsletter.

Top comments (0)