Amplify Up

Lists and queries

Render arrays of records and run search or load-more with queryContent.

A list is an array of records on a component — posts in a grid, logos in a strip, related articles. Each row is editable with the same Field, RichText, and Image components you use for the component's own fields.

Row shape

Every row is a fields object plus a plain id:

fields.posts.value[0];
// → { id: 'post-1', title: { value: 'Ship faster', name: 'title', … }, … }

Two things to notice:

  • id is a plain string, not an envelope. So are slug, _id, and _type.
  • name on a row field is the path inside the record (title, author.name) — not posts.0.title.
import { Field, Image } from '@amplifyup/sdk/react';
import type { Fields, ImageValue } from '@amplifyup/sdk/react';

type Post = { id: string; title: string; slug: string; mainImage: ImageValue };

export function LatestPosts({ fields }: { fields: Fields<{ heading: string; posts: Post[] }> }) {
  const posts = fields.posts.value ?? [];

  return (
    <section>
      <h2>
        <Field field={fields.heading} />
      </h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Image field={post.mainImage} />
            <a href={`/blog/${post.slug}`}>
              <Field field={post.title} />
            </a>
          </li>
        ))}
      </ul>
    </section>
  );
}

Field is for one scalar field. Passing the list or the whole row is a type error:

// ✗ — the list itself
<Field field={fields.posts} />

// ✗ — the whole row
<Field field={post} />

// ✓ — one field on the record
<Field field={post.title} />

Read post.slug and post.id as plain strings — they are identifiers, not editable content.

Editing a list

Editing post.title on the canvas edits the post, not the page. If the same post appears in a grid and in a "related" list, one edit updates both.

What authors do where:

ActionWhere
Change a row's text or imageOn the canvas
Add or remove records, reorder themComposer props panel

There is no add, remove, or drag handle on the canvas. Your component only renders the rows it is given.

Scalar lists

Arrays of strings or numbers stay a single field — tags, a multi-select. They are not records and have no rows:

// fields.tags is Field<string[]>
{
  (fields.tags.value ?? []).map((tag) => <span key={tag}>{tag}</span>);
}

Runtime queries

Page resolve covers data known at Deploy. For data a visitor asks for — search, load more, a picker — use queryContent. The browser asks Amplify Up, Amplify Up asks your content source. No content credentials or query syntax live in your site.

Mark a query connection paginated in Composer and the list prop gains a sibling prop with pagination meta, including the published query spec. A list prop named posts gives you postsPagination:

'use client';
import { queryContent, nextPageSpec, searchSpec } from '@amplifyup/sdk/react';
import type { Fields, QueryPagination } from '@amplifyup/sdk/react';

export function ArticleGrid({
  fields,
  postsPagination,
}: {
  fields: Fields<{ posts: Post[] }>;
  postsPagination?: QueryPagination;
}) {
  // …
}

postsPagination is { hasMore, limit, offset, nextOffset, spec }. Pass it to the helpers — never rebuild spec yourself:

// next page
const more = await queryContent({
  trackingId,
  route: '/insights',
  spec: nextPageSpec(postsPagination),
});

// search
const hits = await queryContent({
  trackingId,
  route: '/insights',
  spec: searchSpec(postsPagination, 'title', term),
});

trackingId is the same one you passed to AmplifyUpProvider, and route is the published path the query belongs to.

Rows come back in the same shape as list rows, so one renderer handles both:

const posts = hits ?? fields.posts.value ?? [];

return posts.map((post) => (
  <article key={post.id}>
    <h2>
      <Field field={post.title} />
    </h2>
  </article>
));

Only entities published with that route can be queried, and visitors only see published content. queryContent is also available from @amplifyup/sdk/server.

When a row is editable

A row is editable when the SDK knows which record produced it. That happens on its own in the common cases:

Rows came fromEditable in Composer?
A list prop on your componentYes
queryContent using nextPageSpec or searchSpecYes
queryContent with a spec you assembled by handNo
Records you fetched yourself and passed in as propsNo

Rows that are not editable still render their text everywhere. They just are not clickable in Composer, and in development the SDK logs once per field:

[AmplifyUp SDK] title has no write target; rendered read-only.

The fix is always upstream — give the rows a real source. Never try to patch the row yourself.

// ✓ the spec Composer published; rows stay editable
spec: searchSpec(postsPagination, 'title', term),

// ✗ hand-built spec with no connection behind it; rows render read-only
spec: { providerId: '', entity: '', filter: [], sort: [], limit: 10, offset: 0 },

Always start from postsPagination.spec. nextPageSpec and searchSpec do that for you, which keeps filters and sort owned by Composer.

Do / Don't

DoDon't
<Field field={post.title} /><Field field={fields.posts} /> or <Field field={post} />
key={post.id} — a plain stringpost.id.value
render query rows with the same components as list rowstreat query rows as raw content objects
nextPageSpec / searchSpec from the published list prophand-build a spec and expect editable rows
let the producer decide what is editableadd or copy a write target yourself
add and reorder records in the props panelbuild add/remove controls on the canvas