React

shadcn tabs in the URL

Keep the Tabs value in a URL parameter: the link becomes shareable, a refresh keeps the view and the back button still works.

Key points

  • A URL parameter instead of useState makes the view shareable and reloadable.
  • The value read from the URL must be validated against a closed list, or a reader can open a view that does not exist.
  • router.replace adds no history entries, router.push does: the choice changes the back button.

Short answer: keep the Tabs value in a URL parameter, not in useState. The component stays controlled, the link becomes shareable and the back button returns to the previous view.

The component

"use client";

import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";

const VIEWS = ["panoramica", "attivita", "fatture"] as const;
type View = (typeof VIEWS)[number];

function parseView(raw: string | null): View {
  return VIEWS.includes(raw as View) ? (raw as View) : "panoramica";
}

export function SettingsTabs() {
  const router = useRouter();
  const pathname = usePathname();
  const params = useSearchParams();
  const view = parseView(params.get("tab"));

  function onValueChange(next: string) {
    const query = new URLSearchParams(params.toString());
    query.set("tab", next);
    router.replace(pathname + "?" + query.toString(), { scroll: false });
  }

  return (
    
      
        {VIEWS.map((item) => (
          
            {item}
          
        ))}
      
      Contenuto della panoramica
      Contenuto delle attività
      Contenuto delle fatture
    
  );
}

Two details make the component stable: value always comes from parseView, so it never holds a value outside the list, and onValueChange rewrites only the tab parameter without dropping the others. The rest of the query survives navigation between views.

What changes against useState

  • Shareable link: you paste the address into a chat and the reader lands on the same tab, not the first one.
  • Refresh and history: reloading no longer returns to the initial view, and the back button goes to the previous tab.
  • Correct first render: the server already draws the right view, so there is no switch from the first tab to the requested one.

The invalid value

A hand-written parameter can carry anything. parseView compares the value with the closed list and falls back to the default: without that check the component shows an empty panel and the reader has no idea why.

The command

npx shadcn@latest add tabs

Live records in the catalogue

For the visual side: the shadcn library, the expandable-tabs component as an animated variant, and the settings blocks that already lay out tabs.

The limitation

Each tab change with replace leaves no trace in history, so the back button skips the previous tab; with push every tab becomes an entry and the back button fills up. In the App Router navigation goes through the server, so on a heavy page a tab change costs a request: when the panels are large, mount them once and hide them.

Where to continue

The component distribution model is in what shadcn/ui is; the route structure is in App Router structure; the comparison with another library is in shadcn/ui vs MUI. To understand what an extra server round trip costs, read INP and responsiveness.

Frequently asked questions

replace or push for tabs?

Replace when the tabs are views of one page and you do not want to fill the history; push when each tab must be reachable with the back button. The choice changes what the reader expects from that button.

Why does useSearchParams need Suspense?

In the App Router reading the parameters is dynamic, so the component that does it needs a Suspense boundary when the page is static, otherwise the build reports a prerender error.

Can I use Tabs without Next.js?

Yes. With React Router the same idea uses the router useSearchParams: read the parameter, validate the value and update the query when the tab changes.

Does the parameter need a fixed name?

It pays off: tab is short, readable and does not collide with filters. A generic name such as view turns ambiguous once the page has more than one tab group.

Does URL state break indexing?

No, as long as every parameter renders the same canonical page and creates no duplicates. The canonical stays the address without a query, unless the view is genuinely distinct content.