DEV Community

Cover image for 7 Next.js Features That Made Me Delete 200 Lines of Code
Franklin Mayoyo
Franklin Mayoyo

Posted on

7 Next.js Features That Made Me Delete 200 Lines of Code

My last project had a photo modal with its own context provider, a tab switcher with three useState calls, and a cache wrapper I'd copy-pasted into four different files. None of that needed to exist. The App Router already handles it.

I went back through a real codebase and pulled this stuff out one feature at a time. Here's what got deleted, what replaced it, and where each one actually bites you. None of this is a free lunch.

1. Server Actions (no more API routes)

Before: a route handler, a fetch call, and state just to know if the button is loading.

// app/api/subscribe/route.ts
export async function POST(req: Request) {
  const { email } = await req.json();
  await db.subscribers.create({ email });
  return Response.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode
// SubscribeForm.tsx
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

async function handleSubmit(e: FormEvent) {
  e.preventDefault();
  setLoading(true);
  try {
    const res = await fetch("/api/subscribe", {
      method: "POST",
      body: JSON.stringify({ email }),
    });
    if (!res.ok) throw new Error("Failed");
  } catch (err) {
    setError("Something went wrong");
  } finally {
    setLoading(false);
  }
}
Enter fullscreen mode Exit fullscreen mode

After:

// actions.ts
"use server";
export async function subscribe(formData: FormData) {
  const email = formData.get("email") as string;
  await db.subscribers.create({ email });
}
Enter fullscreen mode Exit fullscreen mode
// SubscribeForm.tsx
<form action={subscribe}>
  <input name="email" type="email" />
  <button>Subscribe</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Why it's better: the route file is gone. The fetch call is gone. useFormStatus gets you loading state without the extra hook if you still want it. One function does the whole job, and it's obvious where server code ends and client code begins instead of that boundary living implicitly in a URL string.

Cons: you'll still write client code for decent UX. useFormStatus, useActionState, optimistic updates. So it's not "no client code," more like "client code somewhere else." Debugging changes shape too. You're not curling an endpoint anymore, you're stepping through a function call, and that throws people used to REST muscle memory.

2. Parallel Routes (no more tab state)

Before:

const [activeTab, setActiveTab] = useState<"analytics" | "team">("analytics");

return (
  <>
    <TabButton onClick={() => setActiveTab("analytics")} />
    <TabButton onClick={() => setActiveTab("team")} />
    {activeTab === "analytics" ? <Analytics /> : <Team />}
  </>
);
Enter fullscreen mode Exit fullscreen mode

After:

app/dashboard/
  @analytics/page.tsx
  @team/page.tsx
  layout.tsx
Enter fullscreen mode Exit fullscreen mode
// layout.tsx
export default function Layout({ analytics, team }: { analytics: React.ReactNode; team: React.ReactNode }) {
  return (
    <>
      {analytics}
      {team}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why it's better: each slot fetches, streams, and errors on its own. Nothing waits on anything else, and there's no shared state whose only job is telling a child component "you're the active one now." The URL owns the state instead of React.

Cons: don't reach for this unless the sections are actually independent. Two tabs sharing the same data source with no separate fetching? A plain useState and a ternary is simpler, and this is just adding folders for the sake of it. It also reads as magic to anyone new to the codebase until they've hit it once themselves.

3. Intercepting Routes (no more modal state)

Before: a context or store whose entire purpose is remembering which photo is open.

const [openPhotoId, setOpenPhotoId] = useState<string | null>(null);

{openPhotoId && (
  <Modal onClose={() => setOpenPhotoId(null)}>
    <PhotoDetail id={openPhotoId} />
  </Modal>
)}
Enter fullscreen mode Exit fullscreen mode

After:

app/photos/
  page.tsx
  [id]/page.tsx        // full page view
  (.)[id]/page.tsx      // intercepted modal view
Enter fullscreen mode Exit fullscreen mode

Click a photo from the grid and you get (.)[id] as a modal on top of the grid. Refresh that URL, or share the link, and you get the full [id]/page.tsx instead. Same route, right behavior for the context.

Why it's better: the modal is a real URL. Shareable, refreshable, back button just works, and you didn't build any of that. No provider whose only job was tracking an ID.

Cons: the (.), (..), (...) naming is confusing right up until it clicks, and when it doesn't render the modal you expect, the fix is almost always a routing detail, not your component. If you've only got one modal in the whole app, this is more setup than it's worth. Save it for when the pattern repeats.

4. after() (no more fire-and-forget hacks)

Before:

export async function POST(req: Request) {
  const result = await handleOrder(req);
  // not awaited on purpose, but it can get killed before it finishes
  logAnalyticsEvent("order_placed").catch(console.error);
  return Response.json(result);
}
Enter fullscreen mode Exit fullscreen mode

After:

import { after } from "next/server";

export async function POST(req: Request) {
  const result = await handleOrder(req);
  after(() => logAnalyticsEvent("order_placed"));
  return Response.json(result);
}
Enter fullscreen mode Exit fullscreen mode

Why it's better: after() actually runs after the response goes out, instead of you hoping the runtime doesn't tear the function down mid-flight. This one's less about deleting code and more about deleting a bug you didn't know you had.

Cons: the win here is small in line count. Call it a correctness fix wearing a boilerplate-deletion costume. It's also runtime-dependent, so check that your host actually honors it before you trust it with anything that matters, like billing events.

5. loading.tsx and error.tsx (no more manual boundaries)

Before:

function Page() {
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchData()
      .then(setData)
      .catch(setError)
      .finally(() => setIsLoading(false));
  }, []);

  if (error) return <ErrorFallback error={error} />;
  if (isLoading) return <Spinner />;
  return <Content data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

After:

// loading.tsx
export default function Loading() {
  return <Spinner />;
}

// error.tsx
"use client";
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return <ErrorFallback error={error} onRetry={reset} />;
}

// page.tsx
export default async function Page() {
  const data = await fetchData();
  return <Content data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Why it's better: the page component just describes the happy path again. Loading and error states stop being state you thread through every data-fetching component and become a file that sits next to it instead.

Cons: error.tsx only catches what's below it in the tree. Put it in the wrong spot and it silently doesn't catch what you thought it would. loading.tsx is Suspense under the hood, so if you don't understand what's actually being suspended, you'll get a spinner that shows for the wrong thing, or not at all.

6. Cache Components and use cache (no more cache wrappers)

Before:

import { unstable_cache } from "next/cache";

const getPosts = unstable_cache(
  async () => db.posts.findMany(),
  ["posts"],
  { revalidate: 3600, tags: ["posts"] }
);
Enter fullscreen mode Exit fullscreen mode

After:

async function getPosts() {
  "use cache";
  return db.posts.findMany();
}
Enter fullscreen mode Exit fullscreen mode

Why it's better: caching lives on the function itself instead of a wrapper you had to remember to reach for and a cache key you had to name by hand. Everything's dynamic by default now, and you opt specific things into caching, which is a safer default than the old implicit behavior even if it means re-thinking how you reasoned about it before.

Cons: this is fresh, Next.js 16 era, so the docs and the "here's what actually happens in production" knowledge are thinner than the older API. Flipping from "cached unless I say otherwise" to "dynamic unless I say otherwise" isn't a find-and-replace on an existing app. It's a real audit if you care about correctness.

7. Route Groups (no more layout gymnastics)

Before: duplicated layout code, or one root layout with branches in it faking two different UIs. Marketing pages on one side, the actual app on the other.

After:

app/
  (marketing)/
    layout.tsx     // marketing nav/footer
    page.tsx
    pricing/page.tsx
  (app)/
    layout.tsx     // app shell, sidebar
    dashboard/page.tsx
Enter fullscreen mode Exit fullscreen mode

Both groups still resolve to / and /dashboard. The parentheses never touch the URL, they just let each half of the app own its own shell.

Why it's better: no more root layout with an if (pathname.startsWith('/dashboard')) buried in it. Each section owns its layout cleanly, and you stop dreading touching the root layout because you might break the other half of the app.

Cons: this one's organizational, not runtime. It won't move your line count the way Server Actions will, so don't expect it to. It's also easy to over-group early, before you actually know how the app should be split, which just means renaming folders in three months.

The actual point

The line count was never really the story. The framework now carries complexity that used to be yours to hand-roll and maintain: loading states, cache invalidation, modal routing. That's a real trade though, not a clean win. Every one of these ties you tighter to Next.js's way of doing things, and a couple (Cache Components especially) mean you're an early adopter with all that entails, not someone catching up to a settled pattern.

What would you add to this list?

Top comments (0)