<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Phillip Ssempeebwa</title>
    <description>The latest articles on Forem by Phillip Ssempeebwa (@philldevcoder).</description>
    <link>https://forem.com/philldevcoder</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1555375%2F6e08b2f7-caf8-4ce3-b805-f22784499a64.jpg</url>
      <title>Forem: Phillip Ssempeebwa</title>
      <link>https://forem.com/philldevcoder</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/philldevcoder"/>
    <language>en</language>
    <item>
      <title>Ship It Without Drama: 12 Hard-Won Lessons from Building Production Next.js Apps</title>
      <dc:creator>Phillip Ssempeebwa</dc:creator>
      <pubDate>Wed, 27 Aug 2025 19:31:10 +0000</pubDate>
      <link>https://forem.com/philldevcoder/ship-it-without-drama-12-hard-won-lessons-from-building-production-nextjs-apps-3939</link>
      <guid>https://forem.com/philldevcoder/ship-it-without-drama-12-hard-won-lessons-from-building-production-nextjs-apps-3939</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1) Pick a single API style and stick to it
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2) Validate once, reuse everywhere
&lt;/h2&gt;

&lt;p&gt;You shouldn’t be rewriting the same rules on the client, server, and DB. Put your schema in one place and import it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// /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&amp;lt;typeof profileSchema&amp;gt;;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server action uses the same schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// /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 };
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client form gets UX validation but server remains the source of truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// /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&amp;lt;ProfileInput&amp;gt;({ resolver: zodResolver(profileSchema) });

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

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

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

      &amp;lt;input type="number" {...register("age")} placeholder="Age" className="border p-2 w-full" /&amp;gt;

      &amp;lt;SubmitButton&amp;gt;Save&amp;lt;/SubmitButton&amp;gt;
    &amp;lt;/form&amp;gt;
  );
}

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

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3) Kill “magic” data fetching
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  4) Observability before features
&lt;/h2&gt;

&lt;p&gt;If you can’t answer “What broke?” in 30 seconds, you’re gambling. Add:&lt;/p&gt;

&lt;p&gt;Structured logs (&lt;code&gt;JSON.stringify({ level, msg, ...meta })&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Request IDs&lt;/p&gt;

&lt;p&gt;A dead simple /healthz route that checks DB + external dependency&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// /src/lib/log.ts
export function log(level: "info"|"warn"|"error", msg: string, meta: Record&amp;lt;string, unknown&amp;gt; = {}) {
  // Your logger can forward to whatever sink you use
  console.log(JSON.stringify({ ts: new Date().toISOString(), level, msg, ...meta }));
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5) Feature flags: environment, not branches
&lt;/h2&gt;

&lt;p&gt;You don’t need a platform to start. A single flags.ts controls rollouts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// /src/lib/flags.ts
export const flags = {
  newCheckout: process.env.NEXT_PUBLIC_FLAG_NEW_CHECKOUT === "1",
};

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gate the code paths and ship. Flip when you’re ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  6) Stop uploading files through your API route
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// /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 }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// /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;
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  7) Schema migrations are not a suggestion
&lt;/h2&gt;

&lt;p&gt;“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.&lt;/p&gt;

&lt;h2&gt;
  
  
  8) Small PRs or nothing
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  9) Don’t YOLO secrets
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;.env.example&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  10) Error budgets &amp;gt; “move fast”
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  11) Reduce cognitive load for new teammates
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  12) Write a first-hour runbook
&lt;/h2&gt;

&lt;p&gt;When something breaks, nerves are high and brains are slow. Your runbook should be one page:&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A. “Null island” guards&lt;/strong&gt;.&lt;br&gt;
Return early when preconditions aren’t met. Don’t let bad state wander through the app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (!session?.user?.id) {
  return redirect("/login?next=/checkout");
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;B. Explicit timeouts&lt;/strong&gt;.&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;C. Idempotency keys&lt;/strong&gt;.&lt;br&gt;
For payments, webhooks, and “dangerous” writes, require an idempotency key and store it. Double clicks and retries become safe.&lt;/p&gt;

&lt;p&gt;A blunt pre-launch checklist&lt;/p&gt;

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

&lt;p&gt;If this reads “obvious,” good—obvious is exactly what you want at 2 a.m. Boring systems make money.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>nextjs</category>
      <category>react</category>
    </item>
  </channel>
</rss>
