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>;
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 };
}
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>;
}
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 }));
}
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",
};
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 }
}
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;
}
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");
}
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
- Single API style chosen and written down
- All public pages have revalidate rules; user pages don’t cache
- Zod (or equivalent) schemas exist for every form and input
- Health check hits DB + one external dependency
- Structured logs with request IDs
- Direct signed file uploads; API never proxies blobs
- Migrations scripted and tested on a prod copy
-
.env.example
is up to date; secrets are not in Git - Feature flags in place for risky changes
- 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)