Skip to content
Consultation

Switching to a Grid, a Show More Pattern, and One SEO Trade-off

Adityo Guni Waluyo

Refactoring a long accordion column into a 3-column grid with Show More lists. The trade-off: sliced points never reach the initial DOM Google indexes.

I was tidying up a client's services page last week. The old layout was a single column of nine numbered accordion cards. It felt honest, like a plain list of what they offer. But on mobile it just kept scrolling. Nine cards, each with a few bullet points, and the first one open by default. You'd swipe forever, wondering if the page would ever end. The client wanted something compact for the redesign.

We moved to a grid. The new design uses grid-cols-1 md:grid-cols-2 lg:grid-cols-3 with compact cards, an icon per section pulled from an iconMap keyed by Indonesian section titles, and a "Show More" button for long lists. Same pattern applied to training programs, limited to six entries, with cards kept equal height via items-stretch and h-full. That's where the old accordion pattern fell apart.

The grid broke the old accordion

The previous version kept the first card open with useState(index === 0). In a single column that's fine, you see the top item expanded. But in a three-column grid, the first row would have three open cards. That's noise. Three expanded sections screaming at once, pushing the rest down. So I closed them all. Instead of accordions, each card shows a sliced list of points. First five render, the rest wait behind a button. For training programs I capped at six.

The Show More state lives in the card component. Here's the pattern I used:

"use client";
import { useState } from "react";
export function ServiceCard({ points }: { points: string[] }) {
  const [open, setOpen] = useState(false);
  const shown = open ? points : points.slice(0, 5);
  return (
    <div>
      {shown.map(p => <li key={p}>{p}</li>)}
      {!open && <button onClick={() => setOpen(true)}>Show More</button>}
    </div>
  );
}

That's the whole trick. The card needs useState, so it's marked "use client". The page itself stays a Server Component, fetching the JSON data and mapping cards. No need to make the whole page interactive. The [Next.js server and client components docs](https://nextjs.org/docs/app/getting-started/server-and-client-components) explain this boundary well, and the [Next.js use-client directive reference](https://nextjs.org/docs/app/api-reference/directives/use-client) shows why the directive sits on the component file, not the page.

Server page, client card

Keeping page.tsx as a Server Component matters. It fetches the service data, builds the grid, and passes points to each ServiceCard. Only the card carries the "use client" line. That keeps the bundle small and the initial render on the server. The [React conditional rendering docs](https://react.dev/learn/conditional-rendering) cover the {!open && ...} pattern, though it's bread-and-butter stuff for anyone who's touched React.

But here's the part that kept me up: the sliced points aren't in the first HTML response. When open is false, points.slice(0, 5) runs and the rest never hit the DOM. A user clicks, React swaps in the full list. Fine for humans. Not fine for Google.

The SEO trade-off I accepted

Mobile-first indexing means Google primarily crawls and indexes the mobile version of a page. The rendered mobile content is what gets stored. If points six through nine are sliced out of the initial render, they're basically invisible to the crawler. I checked the [Google mobile-first indexing guide](https://developers.google.com/search/docs/crawling-indexing/mobile/mobile-sites-mobile-first-indexing) to confirm: yes, content not in the served HTML is unlikely to be indexed.

For this client, the services page is a marketing list. The hidden points are minor elaborations, not keyword gold. I picked the slice + Show More approach for visual cleanliness. I knew the trade-off: those extra bullets nearly vanish from SEO. If those points carried important keywords, the right move would be to render everything and hide with CSS (display: none toggled by a class), not client-side slicing. That way the HTML contains the text, and the crawler sees it.

I'm a lazy developer; I'd rather ship the smallest diff that works. The slice pattern is one component, no new dependencies. But I won't pretend it's free. The decision was mine, and I'm okay with a quieter long tail on search until the client proves those points matter.

Related reading: the certification badge with no image that crashed next/image and why TypeScript types never reach your JSON.

Related articles