DEV Community

Cover image for Ship It Without Drama: 12 Hard-Won Lessons from Building Production Next.js Apps
Phillip Ssempeebwa
Phillip Ssempeebwa

Posted on

Ship It Without Drama: 12 Hard-Won Lessons from Building Production Next.js Apps

I love shiny tools, but the internet doesn’t pay you for “vibes.” It pays you for shipping reliable software. Here are the lessons I wish someone had shoved in my face before my first serious Next.js launch. No fluff. Just what actually prevents 2 a.m. fire drills.

1) Pick a single API style and stick to it

REST + tRPC + random server actions = spaghetti. Choose one primary surface. If you’re on the App Router, server actions are fine for simple CRUD. For anything bigger, pick REST or tRPC and make it a rule. Consistency beats clever.

2) Validate once, reuse everywhere

You shouldn’t be rewriting the same rules on the client, server, and DB. Put your schema in one place and import it.

// /src/schemas/profile.ts
import { z } from "zod";

export const profileSchema = z.object({
  fullName: z.string().min(3, "Name is too short"),
  email: z.string().email(),
  age: z.number().int().min(13).optional(),
});

export type ProfileInput = z.infer<typeof profileSchema>;

Enter fullscreen mode Exit fullscreen mode

Server action uses the same schema:

// /src/actions/updateProfile.ts
"use server";

import { profileSchema } from "@/schemas/profile";
import { db } from "@/server/db"; // your Prisma/Drizzle wrapper

export async function updateProfile(formData: FormData) {
  const raw = Object.fromEntries(formData.entries());
  const parsed = profileSchema.safeParse({
    fullName: raw.fullName,
    email: raw.email,
    age: raw.age ? Number(raw.age) : undefined,
  });

  if (!parsed.success) {
    return { ok: false, errors: parsed.error.flatten().fieldErrors };
  }

  await db.user.update({ where: { email: parsed.data.email }, data: parsed.data });
  return { ok: true };
}

Enter fullscreen mode Exit fullscreen mode

Client form gets UX validation but server remains the source of truth:

// /src/app/profile/page.tsx
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { profileSchema, type ProfileInput } from "@/schemas/profile";
import { experimental_useFormStatus as useFormStatus } from "react-dom";
import { updateProfile } from "@/actions/updateProfile";

export default function ProfilePage() {
  const { register, handleSubmit, formState: { errors } } =
    useForm<ProfileInput>({ resolver: zodResolver(profileSchema) });

  const onSubmit = async (values: ProfileInput) => {
    const fd = new FormData();
    Object.entries(values).forEach(([k, v]) => v != null && fd.append(k, String(v)));
    await updateProfile(fd);
  };

  return (
    <form action={updateProfile} onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <input {...register("fullName")} placeholder="Full name" className="border p-2 w-full" />
      {errors.fullName && <p className="text-red-600">{errors.fullName.message}</p>}

      <input {...register("email")} placeholder="Email" className="border p-2 w-full" />
      {errors.email && <p className="text-red-600">{errors.email.message}</p>}

      <input type="number" {...register("age")} placeholder="Age" className="border p-2 w-full" />

      <SubmitButton>Save</SubmitButton>
    </form>
  );
}

function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus() as { pending: boolean };
  return <button disabled={pending} className="px-4 py-2 rounded bg-black text-white">
    {pending ? "Saving..." : children}
  </button>;
}

Enter fullscreen mode Exit fullscreen mode

3) Kill “magic” data fetching

No mystery “it works on my machine.” Write down your caching plan: what is cached, where, and for how long. For public data, use fetch(..., { next: { revalidate: 60 } }). For user-specific data, skip caching and be explicit.

4) Observability before features

If you can’t answer “What broke?” in 30 seconds, you’re gambling. Add:

Structured logs (JSON.stringify({ level, msg, ...meta }))

Request IDs

A dead simple /healthz route that checks DB + external dependency

// /src/lib/log.ts
export function log(level: "info"|"warn"|"error", msg: string, meta: Record<string, unknown> = {}) {
  // Your logger can forward to whatever sink you use
  console.log(JSON.stringify({ ts: new Date().toISOString(), level, msg, ...meta }));
}
Enter fullscreen mode Exit fullscreen mode

5) Feature flags: environment, not branches

You don’t need a platform to start. A single flags.ts controls rollouts.

// /src/lib/flags.ts
export const flags = {
  newCheckout: process.env.NEXT_PUBLIC_FLAG_NEW_CHECKOUT === "1",
};

Enter fullscreen mode Exit fullscreen mode

Gate the code paths and ship. Flip when you’re ready.

6) Stop uploading files through your API route

Proxying files through Next API routes is how you earn 413 errors and slow servers. Use direct, signed uploads. Here’s a clean Supabase example that avoids server bloat.

// /src/app/api/upload/route.ts (App Router)
import { NextRequest, NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // keep this server-side only
);

export async function POST(req: NextRequest) {
  const { path } = await req.json();
  const { data, error } = await supabase
    .storage
    .from("avatars")
    .createSignedUploadUrl(path);

  if (error) return NextResponse.json({ error: error.message }, { status: 400 });
  return NextResponse.json(data); // { path, token, url }
}

Enter fullscreen mode Exit fullscreen mode

Client:

// /src/components/AvatarUpload.tsx
"use client";
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

export async function uploadAvatar(file: File, userId: string) {
  const path = `users/${userId}/${crypto.randomUUID()}-${file.name}`;
  const res = await fetch("/api/upload", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ path }),
  });

  const { url, token } = await res.json();
  const { error } = await supabase.storage
    .from("avatars")
    .uploadToSignedUrl(path, token, file, { contentType: file.type });

  if (error) throw error;
  return url;
}

Enter fullscreen mode Exit fullscreen mode

RLS tip: lock the avatars bucket by default; only allow writes to storage.objects when auth.uid() = resource.owner.

7) Schema migrations are not a suggestion

“Let’s just change it in the console” is how you lose a weekend. Put migrations in version control and run them the same way in all environments. If your migration takes longer than a coffee, practice it on a copy of prod first.

8) Small PRs or nothing

300 lines changed max. If it’s bigger, you’re hiding risk inside “miscellaneous refactor.” CI should reject PRs without tests for core paths.

9) Don’t YOLO secrets

Use .env.example checked into the repo. Everything else goes into your platform’s secret manager. Rotate keys the moment you feel weird about them. If you pasted a key in Slack, that key is dead.

10) Error budgets > “move fast”

Agree on an SLO (e.g., 99.9% monthly availability for the core flow). If you blow the budget, you stop feature work and fix stability. Velocity without uptime is theater.

11) Reduce cognitive load for new teammates

  • README isn’t a novel. It should answer:
  • How do I run it locally?
  • How do I run tests?
  • How do I create a migration?
  • What env vars do I need?
  • Anything else goes in /docs.

12) Write a first-hour runbook

When something breaks, nerves are high and brains are slow. Your runbook should be one page:

  • Where are logs/metrics?
  • How to roll back?
  • Who owns the integration?
  • How to disable the feature flag?
  • Common failures and known fixes ## Bonus: Three patterns that saved me, repeatedly

A. “Null island” guards.
Return early when preconditions aren’t met. Don’t let bad state wander through the app.

if (!session?.user?.id) {
  return redirect("/login?next=/checkout");
}

Enter fullscreen mode Exit fullscreen mode

B. Explicit timeouts.
If a third-party call doesn’t respond in 3–5 seconds, bail and show a fallback. Hanging promises make users think your site is broken.

C. Idempotency keys.
For payments, webhooks, and “dangerous” writes, require an idempotency key and store it. Double clicks and retries become safe.

A blunt pre-launch checklist

  1. Single API style chosen and written down
  2. All public pages have revalidate rules; user pages don’t cache
  3. Zod (or equivalent) schemas exist for every form and input
  4. Health check hits DB + one external dependency
  5. Structured logs with request IDs
  6. Direct signed file uploads; API never proxies blobs
  7. Migrations scripted and tested on a prod copy
  8. .env.example is up to date; secrets are not in Git
  9. Feature flags in place for risky changes
  10. Runbook exists; rollback steps tested

If this reads “obvious,” good—obvious is exactly what you want at 2 a.m. Boring systems make money.

Top comments (0)