Skip to content
One Card, Many Posts: Render Lists Without Repeating Code
Milos KnezevicMilos Knezevic02 Nov 2026 · 25 min read

One Card, Many Posts: Render Lists Without Repeating Code

Stop copying the same card twelve times. Write it once, keep the data in an array, and loop. Plain JavaScript, React keys, and the innerHTML trap, with a demo.

Here's the short answer, so you have it before anything else. Put the things that change, like the title, the text, the image and the date, in a list of data. Write the markup for one card once, as a function or a component. Then loop over the list and call that function for every item. Change the function and every card changes with it. That's the whole pattern behind every blog list, product grid and feed you've ever scrolled.

If you're a beginner, read this top to bottom, and click the live demo in the middle, because seeing it is worth more than my words. If you already loop over arrays every day, jump to the section about innerHTML, and then the one about keys in React. Those two are where people who know the basics still get hurt. The last sections, on missing data, dates, big lists and code an AI wrote for you, are for those of you who review other people's code.

The problem: twelve copies of the same card

This is how almost everyone starts. You build one card, it looks good, so you copy it. Then you copy it again. Here are three of them, and in a real page there would be twelve.

index.html
<div class="grid">
  <article class="card">
    <img src="/images/css.webp" alt="A stylesheet open next to a web page" width="320" height="180">
    <h2>First steps with CSS</h2>
    <p>Colors, spacing and fonts, explained from zero.</p>
    <time datetime="2026-10-12">Oct 12, 2026</time>
  </article>
  <article class="card">
    <img src="/images/forms.webp" alt="A sign-up form with two fields" width="320" height="180">
    <h2>How forms work</h2>
    <p>What really happens when you press submit.</p>
    <time datetime="2026-10-19">Oct 19, 2026</time>
  </article>
  <article class="card">
    <img src="/images/errors.webp" alt="A red error message in the browser console" width="320" height="180">
    <h2>Reading error messages</h2>
    <p>The red text is trying to help you.</p>
    <time datetime="2026-10-26">Oct 26, 2026</time>
  </article>
</div>

Look at what's actually different between those blocks. The image file, the alt text, the title, one sentence and a date. Everything else, the article tag, the class names, the order of the elements, the width and height of the image, is identical. You typed the same structure three times to change five small values.

And it works. That's the trap. Nothing is broken on the day you write it. It breaks on the day something changes. Someone asks for the author's name under the title, and now you open the file and make the same edit twelve times. You'll miss one. You'll put the name above the title in card seven because you were tired. Or a new post comes out and you copy the block again, and the new card quietly has last month's date because you forgot to change it.

Here's the thing that annoys me about how this gets taught. Courses love exercises like pulling a name out of an array, and you do it, and you have no idea where that shows up in a real website. This is where it shows up. A list of blog posts is an array. A card is what you do with one item of it. I wrote more about that gap in why courses leave you feeling like you know nothing, and this article is one of the places where the pieces finally connect.

Why HTML makes you repeat yourself

HTML isn't a programming language. It's a description of a document: here's a heading, here's a paragraph, here's an image. It has no way to say "do this for every post". There's no loop in HTML, no variables, no functions. If you've only written HTML so far, like in building your first web page, copying is the only tool you have. So you're not doing anything wrong. You've just reached the edge of what HTML can do by itself.

To go past that edge you need something that can repeat things for you. In the browser that's JavaScript. On a server it could be any language: PHP, Python, JavaScript again in Node.js. The idea is the same everywhere, which is why it's worth learning properly once.

Think about wedding invitations. Nobody designs two hundred invitations. Somebody designs one card, with a blank where the guest's name goes. Then there's a guest list. And then for every name on the list, somebody prints the card and writes that name in the blank. One design, one list, one repeated action. If the date of the wedding changes, you fix the design once, and every invitation printed after that is right.

Your blog cards are exactly that. The card design is the template. The guest list is your data. Printing one card per guest is the loop.

The idea: separate the data from the template

So we split the page into three parts, and each part has one job.

  • The data: a list of posts, where each post is a small bundle of values. Title, excerpt, date, image, alt text. This is the only part that changes when you publish something new.
  • The template: the markup of one card, written once, with blanks where the values go.
  • The loop: a few lines that walk through the data and fill in the template for each item.

Let's start with the data. In JavaScript, a list is an array and a bundle of named values is an object. So a list of posts is an array of objects.

posts.js
const posts = [
  {
    slug: "first-steps-with-css",
    title: "First steps with CSS",
    excerpt: "Colors, spacing and fonts, explained from zero.",
    date: "2026-10-12",
    image: "/images/css.webp",
    imageAlt: "A stylesheet open next to a web page",
  },
  {
    slug: "how-forms-work",
    title: "How forms work",
    excerpt: "What really happens when you press submit.",
    date: "2026-10-19",
    image: "/images/forms.webp",
    imageAlt: "A sign-up form with two fields",
  },
];

Let's go through it. The square brackets make an array, which is just a list with an order. Inside it, each pair of curly braces is one object, which is one post. Inside the object, every line is a name and a value: slug is "first-steps-with-css", title is "First steps with CSS", and so on. The comma between the two objects separates the posts.

Why is there a slug? It's the post's unique name, the part that ends up in the address bar. We don't show it on the card right now, but we'll need something that's different for every post later, when React asks for keys. Give every item in a list a unique ID from the start and you'll thank yourself.

Why is the date written as "2026-10-12" and not as "Oct 12, 2026"? Because the data should store the fact, and the template should decide how to show it. That year-month-day format is unambiguous and it sorts correctly as plain text. How it looks to a reader is a display decision, and we'll make it in one place.

And notice that imageAlt is its own field. Alt text describes the image for people who can't see it. It belongs to each post, because every image is different. If you skip it here, there's nowhere sensible to add it later.

info

Terms in plain words. An array is an ordered list of values. An object is a bundle of named values, like a form someone filled in. A function is a named piece of code you can run again and again with different inputs. A loop is code that repeats something once for every item in a list. The DOM is the browser's live version of your HTML, the thing JavaScript can change after the page has loaded.

Step 1: write the card once, as a function

Now the template. We write a function that takes one post and gives back one finished card. Every value from the post goes into the right place, and the structure is written exactly once.

render-dom.js
const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });

function createCard(post) {
  const card = document.createElement("article");
  card.className = "card";

  const img = document.createElement("img");
  img.src = post.image;
  img.alt = post.imageAlt;
  img.width = 320;
  img.height = 180;

  const title = document.createElement("h2");
  title.textContent = post.title;

  const excerpt = document.createElement("p");
  excerpt.textContent = post.excerpt;

  const time = document.createElement("time");
  time.dateTime = post.date;
  time.textContent = dateFormat.format(new Date(post.date));

  card.append(img, title, excerpt, time);
  return card;
}

Line by line, because every line is there for a reason.

The first line creates a date formatter. Intl.DateTimeFormat is built into every modern browser and into Node.js, and it turns a date into text for a given language. "en-US" with dateStyle "medium" gives you "Oct 12, 2026". We create it once, outside the function, because there's no reason to build a new formatter for every single card. The timeZone part looks odd, and I'll explain it properly in the section about dates, because it fixes a real bug.

Then function createCard(post). This is our template. It takes one post, the one object, and the word post is just the name we give it inside the function.

document.createElement("article") makes a new article element. It isn't on the page yet. It only exists in memory, like a card on your desk that hasn't been put in the envelope. Then we give it the class name "card" so the CSS can style it.

Next comes the image. We set src from post.image and alt from post.imageAlt, so each card gets its own picture and its own description. The width and height are the same for every card, which is why they're written here and not in the data. They tell the browser how much space to reserve before the image downloads, so the page doesn't jump around while it loads.

The title, the excerpt and the time all follow the same move: create the element, then put text into it with textContent. Remember that word. textContent means "treat this as text, never as HTML". It matters a lot, and you'll see exactly why in a minute.

The time element gets two things. dateTime holds the machine-readable date, the "2026-10-12" from the data, so browsers, search engines and screen readers know what it means. textContent holds the human version, "Oct 12, 2026". Same fact, two audiences.

Finally, card.append adds all four children to the article, in that order, and return card hands the finished card back to whoever called the function. That's the whole template. If you want the author's name under the title tomorrow, you add two lines here and every card on the page gets it.

Step 2: loop over the data and put the cards on the page

We have data and we have a template. The loop is the shortest part.

mount.js
const list = document.querySelector("#posts");
const fragment = document.createDocumentFragment();

for (const post of posts) {
  fragment.append(createCard(post));
}

list.replaceChildren(fragment);

The first line finds the container on the page, the element with the id "posts". That's an empty div you put in your HTML where the cards should appear.

Then we create a DocumentFragment. Think of it as a tray. Instead of carrying each card to the page one at a time, you put them all on the tray and carry the tray over once. The page gets updated in one go.

for (const post of posts) is the loop. Read it out loud: for each post of the posts. On every round, post is the next object from the array, and we pass it to createCard and drop the result on the tray.

The last line, list.replaceChildren(fragment), clears whatever was inside the container and puts the cards there. Using replace instead of append means you can run this again later, after sorting or filtering, without ending up with the old cards and the new cards both on the page.

tip

Honest note on the tray. For ten or twenty cards you will not see any difference between appending each card directly and using a fragment. I use it because it's the same amount of code and it's the habit that holds up when the list gets long. If you appended one by one and it works, you didn't write a bug.

That's three files and maybe forty lines of JavaScript, and it doesn't matter anymore whether you have three posts or three hundred. The page you write stays the same size. Only the data grows.

See it run: one function, any number of cards

Here it is live. Every card below comes from one createCard function and a small array. Click Add a post and watch a new card appear, with no new HTML written. Click Oldest first to sort the same data the other way. Empty the list to see what happens when there's nothing to show. Leave the checkbox alone for now. It's for the next section.

Card list from data

The colored blocks stand in for images, because the demo can't load pictures. In a real page that's the img element from Step 1.

Notice what sorting did. We didn't move any HTML around. We sorted the data and drew the cards again. That's a habit worth building early: change the data, then render again. Don't reach into the page and shuffle elements by hand. When the data is the truth and the page is just a picture of the data, a whole class of bugs disappears, because the picture can never drift away from the truth.

The shortcut everyone writes, and why it bites: innerHTML

If you search for how to render a list, or ask an AI to do it, there's a good chance you'll get something like this instead of createElement. It's shorter and it looks like HTML, which feels nicer.

render-string.js
function cardHTML(post) {
  return `
    <article class="card">
      <img src="${post.image}" alt="${post.imageAlt}" width="320" height="180">
      <h2>${post.title}</h2>
      <p>${post.excerpt}</p>
      <time datetime="${post.date}">${post.date}</time>
    </article>`;
}

document.querySelector("#posts").innerHTML = posts.map(cardHTML).join("");

Here's what it does. cardHTML takes a post and returns one long string of HTML, with the values dropped in using a template literal, the text between backticks where ${...} inserts a value. Then posts.map(cardHTML) runs that function on every post and gives you a new array of strings, one per card. join("") glues them into one big string, and innerHTML hands that string to the browser to turn into elements.

It works, and for text you wrote yourself it's fine. The problem is the word innerHTML. It means "read this string as HTML". So if any value contains something that looks like a tag, the browser builds that tag.

Now check the live demo above and tick "Use innerHTML for titles". The third card's title is literally Why <em>keys</em> matter, with the tags as part of the text. With textContent, you see the tags as characters, which is correct: that's the title. With innerHTML, the word keys turns italic, because the browser treated the text as markup.

Italic text is harmless. But what if the title came from a user, or a form, or a database somebody else can write to? I ran the innerHTML version with a title containing an img tag with an onerror attribute, and the browser created a real img element inside the h2. In a real browser, that onerror code runs. That's called cross-site scripting, XSS for short: somebody else's code running on your page, with access to everything your page can see.

I ran the same title through the createCard version, the one using textContent, and the h2 had zero child elements. The tag was just text on the screen. MDN says it directly on the innerHTML page: when the content is plain text, use textContent instead.

warning

The rule I follow: data goes in with textContent, setAttribute or element properties like img.src. innerHTML only gets strings I wrote myself, never anything that came from a user, a form, a URL or a database. If you must turn outside data into HTML, it needs a sanitizer, and that's a decision for someone who understands the trade-off, not a quick fix.

There's a middle way you'll meet in older code and some libraries: the template element. You write the card's HTML once inside a template tag on the page, the browser doesn't show it, and JavaScript clones it for each post and fills the blanks with textContent. It gives you real HTML to look at and the safe way of filling it in. It's a good tool. I'm not showing the full version here because it's the same idea as createCard with different plumbing.

Dates, missing fields and the empty list

This is the part tutorials skip, and it's where real data hurts you. Demo data is perfect. Real data isn't.

The date that shows up one day early

Remember the timeZone: "UTC" in our formatter? Here's why it's there. A date written as "2026-10-12", with no time, is read by JavaScript as midnight UTC. That's documented on MDN's Date page: date-only forms are interpreted as UTC, date-time forms as local time. When a formatter then shows that moment in the reader's own time zone, anyone west of UTC sees the evening before.

I checked it. The same "2026-10-12" formatted for Los Angeles prints "Oct 11, 2026". Your post says it was published on a different day depending on where the reader lives. Telling the formatter to use UTC as well means it reads and shows the date in the same zone, so everyone sees Oct 12. If your dates carry a real time, like a comment posted at 14:05, then you probably do want the reader's local time, and you'd drop that option. It depends on what the date means.

One broken item takes down the whole list

Now, what if one post has no date? Maybe someone saved a draft without one. I ran createCard on a post with the date missing, and it threw: RangeError: Invalid time value. And because the loop runs all the cards in one go, that one error stops everything. Not one broken card. No cards at all.

So the formatting gets a guard.

format-date.js
function formatDate(value) {
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return null;
  return dateFormat.format(date);
}

new Date(value) tries to read the value as a date. If it can't, you get an invalid date, and getTime on an invalid date returns NaN, which means "not a number". Number.isNaN checks exactly that. If the date is invalid we return null, and the card can simply leave the date out. If it's fine, we format it as before. I ran it with "2026-10-19", with nothing, and with the text "next week", and got "Oct 19, 2026", null and null.

The better fix is one step earlier: check the shape of the data when it enters your app, so a post without a date never reaches the template. That's what validating at trust boundaries with Zod is about. The guard in the template is your Plan B for when the check upstream misses something.

Zero posts is a state too

Empty the list in the demo. If you don't plan for it, an empty array gives you an empty div and a page that looks broken. One if at the top of your render function fixes it: when there are no items, show a sentence instead. It costs three lines and it's the difference between "the site is down" and "there's nothing here yet".

The same idea in React: components and map

If you're working in React or Next.js, nothing about the idea changes. The function that makes one card is now called a component, and the loop is map. Here's the card.

components/PostCard.tsx
import type { Post } from "@/lib/posts";

const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });

export function PostCard({ post }: { post: Post }) {
  return (
    <article className="card">
      <img src={post.image} alt={post.imageAlt} width={320} height={180} />
      <h2>{post.title}</h2>
      <p>{post.excerpt}</p>
      <time dateTime={post.date}>{dateFormat.format(new Date(post.date))}</time>
    </article>
  );
}

The first line imports the Post type, which is a TypeScript description of what one post looks like: slug, title, excerpt, date, image and imageAlt, all strings. It means your editor will complain if you forget a field or misspell one, before the browser ever sees it.

The formatter is created once, outside the component, for the same reason as before. Then PostCard is a function that receives props, and we pull post out of them. What it returns looks like HTML but it's JSX: className instead of class, curly braces where a value goes, and dateTime with a capital T.

Here's something important for the innerHTML story. When you put {post.title} in JSX, React treats it as text, the same as textContent. I rendered this component with that nasty img title and React wrote it out as escaped text, not as a tag. React has a way to insert raw HTML, and it's deliberately named dangerouslySetInnerHTML so you notice when you use it. If you see it in a code review next to user data, stop and ask questions.

Now the list.

components/PostList.tsx
import type { Post } from "@/lib/posts";
import { PostCard } from "./PostCard";

export function PostList({ posts }: { posts: Post[] }) {
  if (posts.length === 0) {
    return <p>No posts yet. The first one is on its way.</p>;
  }

  return (
    <div className="grid">
      {posts.map((post) => (
        <PostCard key={post.slug} post={post} />
      ))}
    </div>
  );
}

The if at the top is our empty state from earlier. Then posts.map runs once per post and returns one PostCard per post. map gives back a new array, here an array of cards, and React knows how to render an array of elements. That's the loop.

Why React asks for a key

Look at key={post.slug}. If you leave it out, the page still renders, but React prints a warning in the console. I ran it to be sure: "Each child in a list should have a unique key prop."

Here's why it exists. When your list changes, React doesn't throw the page away and draw it fresh. It compares the new list with the old one and changes only what's different. To do that it has to know which card is which. Picture a dinner table where the waiter remembers orders by seat number. If two guests swap seats, the coffee goes to the wrong person. If he remembers orders by name, the coffee follows the person. The key is the name.

That's also why the array index, the 0, 1, 2 position, is a bad key when the list can be sorted, filtered or added to at the top. After a sort, item number 0 is a different post, so React thinks the post at seat 0 just changed its title. The React docs on rendering lists say that index as a key often leads to subtle and confusing bugs, and they also warn against generating keys on the fly with something like Math.random. Every render would create new keys, so React would throw away and rebuild every card each time. Use something stable that belongs to the item: a database ID or a slug.

Where the data comes from in Next.js

In a real site the posts don't sit in a hand-written array. They come from a database or a content system. In Next.js with the App Router, the page itself can be an async server component that fetches the posts and hands them to the list. If server components are new to you, React Server Components explained covers what runs where.

app/blog/page.tsx
import type { Metadata } from "next";
import { PostList } from "@/components/PostList";
import { getPosts } from "@/lib/posts";

export const metadata: Metadata = { title: "Blog" };

export default async function BlogPage() {
  const posts = await getPosts();

  return (
    <main>
      <h1>Blog</h1>
      <PostList posts={posts} />
    </main>
  );
}

getPosts is your own function that reads from wherever your posts live. The page waits for it with await, then passes the result to PostList. That's it. The page doesn't know or care whether there are two posts or two thousand.

There's a second reason I like this setup. Because the component runs on the server, the finished cards are already in the HTML the browser receives. With the plain JavaScript version, the cards only exist after the script runs. Google can run JavaScript, and its JavaScript SEO basics explain how, but HTML that already contains the content is the simpler path. I used to think SEO didn't matter much. It does: the best content is invisible if Google never shows it.

So that's all it is

Take away the syntax and every version in this article is the same three things. A list of data. One template that knows how to draw one item. A loop that connects them. createElement and a for loop, map and a component, a server page and a database: same idea, different clothes.

Once you see it, you'll see it everywhere. The product grid in a shop. The rows in your email inbox. The comments under a video. Search results. Every one of them is somebody's data, one template and a loop. When you look at a page now, you can ask: what's the data here, and what's the template? That question is a big step from copying blocks of HTML.

What to watch out for, and your Plan B

This is the section for when it's not a demo anymore.

  • Very long lists. Hundreds of cards are fine. Thousands of rich cards on one page get slow to build and slow to scroll. The usual answer is pagination, showing twenty at a time, which is also friendlier to readers. For truly huge lists, like a table with tens of thousands of rows, there's a technique called virtualization that only draws the rows on screen. Don't reach for it until you measure a problem.
  • Images in a grid. Always set width and height, as in Step 1, so the layout doesn't jump. Cards below the first screen can use loading="lazy" so the browser fetches them only when they're close to view. The first visible image should load normally.
  • Alt text comes from the data. Repeating the title as alt text is not a description. If the image is decorative, an empty alt is honest. If your data has no alt field, that's a gap in your data, not in your template.
  • Duplicate keys. If two posts accidentally share a slug, React warns about duplicate keys and the list can behave strangely. Unique keys are a data rule, so enforce them where the data is created, for example with a unique constraint in the database.
  • Headings inside cards. If the page has an h1 and the cards sit directly below it, h2 in the card is right. If the list sits inside a section that already has an h2, the cards should use h3. One template means the heading level is decided in one place, so make it a prop when the card is used in different places.
  • Code an AI wrote for you. When you ask an AI for a card grid, it will often reach for a template string and innerHTML, because that's short. Check for it. I use AI for a lot of my work and I check what it writes as far as I can, and this is exactly the kind of thing worth checking, because it works perfectly on demo data.

And the Plan B for the whole approach: if something goes wrong inside a list in React, one broken card can still take down the page. Guard the risky parts, like the date, and for bigger apps put an error boundary around sections that render outside data. Error boundaries done right shows how. If your plan B is "hope the data is always clean", that's not a plan B.

What to learn next

Your cards need a layout, and a wall of equal cards is a grid job. Flexbox vs Grid on real layouts explains the repeat and minmax grid the demo above is built on, and why it wraps into new rows by itself. After that, try writing a test for PostList that checks the empty state and the number of cards; testing React applications shows how to set that up.

Then try it on your own page. Find anything you copied more than twice, pull the values that change into an array, and write the template once. The first time it feels like more work than copying. The second time you need to change something, it pays for itself.

I learned most of this by doing it the long way first and getting it wrong. That's fine. Not knowing something yet is fine. You can do this, and you have to. If you weren't capable I wouldn't spend my time on you. Just don't give up, and you'll always have me on your side.