JSON in AWS Amplify: DataStore, GraphQL API, REST API & S3 JSON Storage

Last updated:

AWS Amplify handles JSON through four primary mechanisms: the GraphQL API client for typed query and mutation responses, DataStore for offline-first JSON synchronization, the REST API client for API Gateway endpoints, and Amplify Storage for S3 JSON files. client.graphql({'({ query: listTodos })'}) returns JSON matching the schema with TypeScript types generated from the GraphQL schema. Amplify DataStore synchronizes JSON data between local SQLite and cloud DynamoDB — apps work offline and sync automatically on reconnect with conflict resolution. The REST API client wraps API Gateway: client.rest.get("/endpoint") returns Record<string, unknown>; validate with Zod before using. Amplify Storage stores JSON files in S3: uploadData({'({ path: "report.json", data: JSON.stringify(data) })'}) and downloadData({'({ path: "report.json" })'}) handle large JSON blobs. This guide covers Amplify Gen 2 schema definition, typed GraphQL responses, DataStore JSON sync, REST API with Zod validation, S3 JSON storage, Auth user JSON attributes, and TypeScript code generation for fully typed JSON responses.

Amplify Gen 2: Schema Definition and JSON API Generation

Amplify Gen 2 uses a TypeScript-first schema definition in amplify/data/resource.ts. Every a.model() call defines a DynamoDB table, a set of AppSync GraphQL resolvers, and the TypeScript types for the JSON responses — all from a single source of truth. Field types map directly to JSON types: a.string(), a.integer(), a.float(), a.boolean(), a.json() for arbitrary JSON blobs, and a.enum() for string enums. Running npx ampx sandbox generates src/API.ts and src/graphql/queries.ts — every response type is inferred automatically, no manual interface maintenance required.

// amplify/data/resource.ts — Gen 2 schema definition
import { a, defineData, type ClientSchema } from '@aws-amplify/backend'

const schema = a.schema({
  // ── Todo model — maps to DynamoDB table + GraphQL API ──────────
  Todo: a.model({
    title:     a.string().required(),
    content:   a.string(),
    done:      a.boolean().default(false),
    priority:  a.enum(['LOW', 'MEDIUM', 'HIGH']),
    tags:      a.string().array(),          // JSON array of strings
    metadata:  a.json(),                    // arbitrary JSON blob
    createdAt: a.datetime(),               // ISO 8601 string in JSON
  })
  .authorization(allow => [allow.owner()]),

  // ── Report model — stores JSON blobs and S3 keys ────────────────
  Report: a.model({
    name:       a.string().required(),
    s3Key:      a.string(),               // key of JSON file in S3
    status:     a.enum(['PENDING', 'READY', 'ERROR']),
    resultJson: a.json(),                 // stores inline JSON result
    rowCount:   a.integer(),
  })
  .authorization(allow => [allow.authenticated()]),
})

export type Schema = ClientSchema<typeof schema>
export const data = defineData({ schema, authorizationModes: {
  defaultAuthorizationMode: 'userPool',
} })

// ── Generated types (src/API.ts — do not edit) ──────────────────
// type Todo = {
//   id: string
//   title: string
//   content?: string | null
//   done: boolean
//   priority?: 'LOW' | 'MEDIUM' | 'HIGH' | null
//   tags?: string[] | null
//   metadata?: string | null   ← a.json() serializes to string
//   createdAt?: string | null
//   updatedAt: string
//   owner?: string | null
// }

// ── Configure Amplify in your app ──────────────────────────────────
// src/main.tsx or src/index.tsx
import { Amplify } from 'aws-amplify'
import outputs from '../amplify_outputs.json'

Amplify.configure(outputs)  // amplify_outputs.json generated by sandbox

The a.json() field type stores arbitrary JSON blobs as a serialized string in DynamoDB and returns them as a JSON string in GraphQL responses — you must call JSON.parse() on the value after retrieval if you need the object form. For strongly typed nested objects, define a separate a.model() or use a.customType() to describe the nested shape. Authorization rules set with .authorization() translate to AppSync resolver-level checks — allow.owner() adds a owner field to the model and restricts mutations to the record's creator, while allow.authenticated() permits any signed-in Cognito user to read and write.

GraphQL API: Typed JSON Queries and Mutations

The Amplify GraphQL client — generateClient() from aws-amplify/data — provides type-safe client.models.ModelName.list(), .get(), .create(), .update(), and .delete() methods. Each method returns typed JSON that matches the model definition exactly. For custom operations, use client.graphql({'({ query, variables })'}) with the generated query strings. All responses follow the GraphQL JSON envelope: { data: { operationName: { items: T[] } } } for list operations.

import { generateClient } from 'aws-amplify/data'
import type { Schema } from '@/amplify/data/resource'

const client = generateClient<Schema>()

// ── List query — returns typed JSON array ─────────────────────────
async function fetchTodos() {
  const { data: todos, errors } = await client.models.Todo.list()

  if (errors) {
    console.error('GraphQL errors:', errors)
    return []
  }

  // todos is typed as Todo[] — each field matches the schema definition
  return todos.map(todo => ({
    id:       todo.id,
    title:    todo.title,
    done:     todo.done ?? false,
    priority: todo.priority ?? 'LOW',
    tags:     todo.tags ?? [],
    // a.json() field — must parse the JSON string
    metadata: todo.metadata ? JSON.parse(todo.metadata) : null,
  }))
}

// ── Create mutation — JSON input matches schema ───────────────────
async function createTodo(title: string, tags: string[]) {
  const { data: newTodo, errors } = await client.models.Todo.create({
    title,
    done:     false,
    priority: 'MEDIUM',
    tags,
    metadata: JSON.stringify({ source: 'web', version: 2 }), // stringify a.json()
  })

  if (errors || !newTodo) throw new Error(JSON.stringify(errors))
  return newTodo
}

// ── Update mutation ───────────────────────────────────────────────
async function markDone(id: string) {
  const { data: updated } = await client.models.Todo.update({ id, done: true })
  return updated
}

// ── Filtered list — server-side JSON filtering ───────────────────
async function fetchHighPriority() {
  const { data } = await client.models.Todo.list({
    filter: { priority: { eq: 'HIGH' }, done: { eq: false } },
    limit: 50,
  })
  return data
}

// ── Paginated list — nextToken cursor ────────────────────────────
async function fetchAllTodos() {
  let nextToken: string | null | undefined = undefined
  const all: Schema['Todo']['type'][] = []

  do {
    const { data, nextToken: token } = await client.models.Todo.list({
      limit: 100,
      nextToken,
    })
    all.push(...data)
    nextToken = token
  } while (nextToken)

  return all
}

// ── Real-time subscription — JSON events ─────────────────────────
function subscribeToTodos(onUpdate: (todo: Schema['Todo']['type']) => void) {
  const sub = client.models.Todo.observeQuery().subscribe({
    next: ({ items }) => items.forEach(onUpdate),
    error: (err) => console.error('Subscription error:', err),
  })
  return sub // call sub.unsubscribe() on cleanup
}

observeQuery() combines an initial list query with a real-time subscription — the items array in the callback always reflects the current state of the dataset. This is more efficient than manually combining .list() and .onCreate()/.onUpdate() subscriptions. For paginated datasets, observeQuery automatically fetches all pages and merges subscription events into the cached result. The nextToken cursor in list responses is an opaque JSON string that encodes the DynamoDB LastEvaluatedKey — always treat it as an opaque value and do not attempt to parse it.

DataStore: Offline-First JSON Synchronization

Amplify DataStore is the offline-first layer that sits between your React components and the cloud. It maintains a local SQLite database whose schema mirrors your a.model() definitions. All DataStore operations — query, save, delete — run against local SQLite first and complete synchronously from the app's perspective. When online, DataStore syncs changes in the background using a GraphQL subscriptions channel and a delta-sync query that fetches only records modified since the last sync timestamp.

// DataStore is available in Amplify Gen 1 and Gen 2 (JS library v6+)
import { DataStore, Predicates, SortDirection } from 'aws-amplify/datastore'
import { Todo, Priority } from '@/models'  // generated model classes

// ── Query — reads from local SQLite (instant, works offline) ────
async function getTodos() {
  const todos = await DataStore.query(Todo)
  // todos: Todo[] — fully typed, matches JSON schema
  return todos
}

// ── Filtered query — predicate API ──────────────────────────────
async function getHighPriorityTodos() {
  const todos = await DataStore.query(
    Todo,
    todo => todo.and(t => [
      t.priority.eq(Priority.HIGH),
      t.done.eq(false),
    ])
  )
  return todos
}

// ── Sorted query ─────────────────────────────────────────────────
async function getTodosSorted() {
  return DataStore.query(Todo, Predicates.ALL, {
    sort: s => s.createdAt(SortDirection.DESCENDING),
    limit: 20,
  })
}

// ── Save (create or update) ──────────────────────────────────────
async function createTodo(title: string) {
  const todo = await DataStore.save(new Todo({
    title,
    done:     false,
    priority: Priority.MEDIUM,
    tags:     ['inbox'],
    metadata: JSON.stringify({ source: 'app' }), // JSON string for a.json()
  }))
  return todo
}

// ── Update — must spread the existing record ─────────────────────
async function toggleTodo(id: string, current: Todo) {
  const updated = await DataStore.save(
    Todo.copyOf(current, draft => { draft.done = !current.done })
  )
  return updated
}

// ── Delete ───────────────────────────────────────────────────────
async function deleteTodo(todo: Todo) {
  await DataStore.delete(todo)
}

// ── Observe (real-time subscription + offline sync events) ───────
function watchTodos(onChange: (todos: Todo[]) => void) {
  const subscription = DataStore.observeQuery(Todo).subscribe(
    snapshot => onChange(snapshot.items)
  )
  return () => subscription.unsubscribe()  // cleanup function
}

// ── Conflict resolution configuration (amplify/data/resource.ts) ─
// const schema = a.schema({ ... }).configure({
//   conflictResolution: {
//     defaultHandler: 'AUTOMERGE',   // AUTOMERGE | OPTIMISTIC_CONCURRENCY | LAMBDA
//   }
// })
// AUTOMERGE: merges non-conflicting JSON fields
// OPTIMISTIC_CONCURRENCY: last-write-wins (uses _version integer)
// LAMBDA: custom handler receives { serverData, localData } JSON → returns winner

DataStore serializes all model instances to JSON before persisting to SQLite and deserializes on read. The _version field (an integer) is added automatically by Amplify when Optimistic Concurrency conflict resolution is configured — it increments on every successful mutation and is checked on the server before accepting an update. If the server version does not match the client version, the mutation is rejected and DataStore fires a conflict event. The Todo.copyOf(existing, draft {'=> { ... }'}) pattern is required for updates because DataStore model instances are immutable — copyOf creates a new instance with the draft mutations applied, which DataStore diffs against the stored version to produce the minimal GraphQL update mutation.

REST API: API Gateway JSON Responses with Zod Validation

The Amplify REST client wraps API Gateway HTTP endpoints. Unlike GraphQL, REST responses have no schema metadata — the client returns Record<string, unknown>. Use Zod to validate and narrow the response type before using it. The REST client supports GET, POST, PUT, PATCH, DELETE, HEAD, and automatically injects the Cognito auth token into the Authorization header based on the configured auth mode.

import { get, post, put, del } from 'aws-amplify/api'
import { z } from 'zod'

// ── Zod schemas for REST API response types ───────────────────────
const ProductSchema = z.object({
  id:          z.string(),
  name:        z.string(),
  price:       z.number().positive(),
  inStock:     z.boolean(),
  categories:  z.array(z.string()),
  description: z.string().nullable(),
  updatedAt:   z.string().datetime(),
})
const ProductListSchema = z.object({
  items:     z.array(ProductSchema),
  total:     z.number().int(),
  nextToken: z.string().optional(),
})
type Product     = z.infer<typeof ProductSchema>
type ProductList = z.infer<typeof ProductListSchema>

// ── Validated GET request ─────────────────────────────────────────
async function fetchProducts(nextToken?: string): Promise<ProductList> {
  const queryParams = nextToken ? { nextToken } : {}

  const restOperation = get({
    apiName: 'catalogApi',
    path:    '/products',
    options: { queryParams },
  })

  const response = await restOperation.response
  const json     = await response.body.json()

  const result = ProductListSchema.safeParse(json)
  if (!result.success) {
    // Log the Zod error details; surface a structured error to the caller
    console.error('API schema mismatch:', result.error.flatten())
    throw new Error('Unexpected API response shape from /products')
  }

  return result.data  // typed as ProductList
}

// ── POST request with JSON body ───────────────────────────────────
const CreateProductSchema = z.object({
  name:       z.string().min(1).max(200),
  price:      z.number().positive(),
  categories: z.array(z.string()).max(10),
})
type CreateProductInput = z.infer<typeof CreateProductSchema>

async function createProduct(input: CreateProductInput): Promise<Product> {
  // Validate input before sending
  const parsed = CreateProductSchema.parse(input)

  const restOperation = post({
    apiName: 'catalogApi',
    path:    '/products',
    options: {
      body:    parsed,                    // Amplify serializes to JSON
      headers: { 'x-idempotency-key': crypto.randomUUID() },
    },
  })

  const response = await restOperation.response
  if (response.statusCode !== 201) {
    const error = await response.body.json()
    throw new Error((error as { message?: string }).message ?? 'Create failed')
  }

  const json   = await response.body.json()
  return ProductSchema.parse(json)
}

// ── Error handling ────────────────────────────────────────────────
// Amplify REST throws ApiError with properties:
//   name:    'ApiError'
//   message: HTTP status text
//   response.statusCode: number
//   response.body: ReadableStream (call .json() or .text())

async function safeGet(path: string) {
  try {
    const op       = get({ apiName: 'catalogApi', path })
    const response = await op.response
    return await response.body.json()
  } catch (err: unknown) {
    if (err instanceof Error && err.name === 'ApiError') {
      console.error('API error:', err.message)
    }
    throw err
  }
}

Amplify REST APIs are configured in amplify/backend.ts using defineRestApi() in Gen 2, or declared in amplify/backend/api/ in Gen 1. The API name passed to get(), post(), etc. must match the resource name in amplify_outputs.json. For API Gateway endpoints that return paginated JSON, parse the nextToken field from the validated response and pass it as a query parameter on the next call — Amplify REST does not manage pagination automatically the way DataStore does.

Amplify Storage: JSON Files in S3

Amplify Storage connects to an S3 bucket and handles authentication automatically. Use it to store large JSON blobs that exceed DynamoDB's 400 KB item size limit, export reports as JSON files, or store configuration JSON files that Lambda functions read at runtime. uploadData() accepts a string, Blob, or ArrayBuffer as the data parameter — pass JSON.stringify(payload) for JSON files. downloadData() returns a result with a .body ReadableStream that you convert to text and then parse.

import { uploadData, downloadData, list, remove, getUrl } from 'aws-amplify/storage'

// ── Upload a JSON file to S3 ──────────────────────────────────────
interface ReportData {
  generatedAt: string
  userId:      string
  rows:        Array<{ id: string; value: number; label: string }>
  summary:     { total: number; average: number; count: number }
}

async function uploadReport(userId: string, data: ReportData): Promise<string> {
  const key  = `reports/${userId}/${Date.now()}.json`
  const json = JSON.stringify(data, null, 2)  // pretty-print for readability

  await uploadData({
    path:    key,
    data:    json,
    options: {
      contentType: 'application/json',
      metadata:    { userId, type: 'report' },  // S3 object metadata (strings only)
      onProgress:  ({ transferredBytes, totalBytes }) => {
        if (totalBytes) {
          console.log(`Upload: ${Math.round(transferredBytes / totalBytes * 100)}%`)
        }
      },
    },
  }).result

  return key
}

// ── Download and parse a JSON file ───────────────────────────────
async function downloadReport(key: string): Promise<ReportData> {
  const result      = await downloadData({ path: key }).result
  const jsonString  = await result.body.text()
  const parsed      = JSON.parse(jsonString) as ReportData

  // Validate with Zod if the file is from an untrusted source
  // const validated = ReportDataSchema.parse(parsed)

  return parsed
}

// ── List JSON files by prefix ─────────────────────────────────────
async function listReports(userId: string) {
  const { items } = await list({
    path:    `reports/${userId}/`,
    options: { listAll: false, pageSize: 20 },
  })

  return items.map(item => ({
    key:          item.path,
    size:         item.size,            // bytes
    lastModified: item.lastModified,
  }))
}

// ── Access levels: public / protected / private ───────────────────
// public/  — any authenticated user can read; bucket policy allows guest read
// protected/{identityId}/  — owner writes; others can read with the identityId key
// private/{identityId}/  — only the owning Cognito identity can read/write

// Amplify Storage Gen 2 uses path strings to set access level:
async function uploadUserConfig(config: object) {
  const { identityId } = await fetchAuthSession()
  await uploadData({
    path: `private/${identityId}/config.json`,
    data: JSON.stringify(config),
    options: { contentType: 'application/json' },
  }).result
}

// ── Get a presigned URL (temporary JSON download link) ───────────
async function getDownloadUrl(key: string): Promise<string> {
  const url = await getUrl({
    path:    key,
    options: { expiresIn: 3600 },  // 3600 seconds = 1 hour
  })
  return url.url.toString()
}

// ── Delete a JSON file ────────────────────────────────────────────
async function deleteReport(key: string) {
  await remove({ path: key })
}

S3 object metadata values must be strings — use JSON.stringify(metadataObject) if you need to store a structured JSON object in S3 metadata, then parse it on retrieval. For JSON files larger than 5 MB, Amplify automatically switches to S3 multipart upload with a default part size of 5 MB, supporting files up to 5 TB. The onProgress callback fires once per multipart part. For append-only patterns such as adding JSON log entries to an existing file, download the file first, parse it, append, and re-upload — S3 does not support partial file updates.

Auth: User Attributes JSON

Amplify Auth wraps Amazon Cognito. After sign-in, you can retrieve the currently authenticated user's attributes as a JSON object with fetchUserAttributes(). The attributes object contains standard Cognito attributes (email, sub, name, phone_number) and custom attributes prefixed with custom:. For the full JWT payload — which is itself a JSON object containing all Cognito claims — use fetchAuthSession().

import {
  fetchUserAttributes,
  fetchAuthSession,
  getCurrentUser,
  signIn,
  signOut,
  updateUserAttributes,
} from 'aws-amplify/auth'

// ── fetchUserAttributes — Cognito attribute JSON ──────────────────
async function getUserProfile() {
  const attrs = await fetchUserAttributes()
  // attrs: Record<string, string>
  // Standard Cognito attributes:
  //   attrs.sub           — unique Cognito user ID (UUID)
  //   attrs.email         — email address
  //   attrs.email_verified — "true" or "false" (string!)
  //   attrs.name          — display name
  //   attrs.phone_number  — E.164 format
  //   attrs['custom:role']  — custom attribute example

  return {
    userId:    attrs.sub,
    email:     attrs.email,
    name:      attrs.name,
    role:      attrs['custom:role'] ?? 'user',
    orgId:     attrs['custom:orgId'],
    verified:  attrs.email_verified === 'true',  // must compare as string
  }
}

// ── fetchAuthSession — full JWT payload as JSON ───────────────────
async function getSessionTokens() {
  const session = await fetchAuthSession()

  if (!session.tokens) return null  // not signed in

  // ID token payload — a JSON object
  const idPayload = session.tokens.idToken?.payload
  // idPayload contains all Cognito claims:
  //   iss:    'https://cognito-idp.{region}.amazonaws.com/{poolId}'
  //   sub:    '550e8400...'
  //   aud:    'your-app-client-id'
  //   exp:    1717171717 (Unix timestamp)
  //   iat:    1717168117
  //   email:  'user@example.com'
  //   'cognito:groups': ['Admins']  — Cognito group membership

  const groups = (idPayload?.['cognito:groups'] as string[]) ?? []
  const isAdmin = groups.includes('Admins')

  // Access token payload — use for API authorization
  const accessPayload = session.tokens.accessToken.payload
  const scope         = accessPayload.scope as string

  return { idPayload, accessPayload, isAdmin, scope }
}

// ── getCurrentUser — lightweight identity check ───────────────────
async function checkAuth() {
  try {
    const { username, userId, signInDetails } = await getCurrentUser()
    return { authenticated: true, username, userId }
  } catch {
    return { authenticated: false }
  }
}

// ── Update custom attributes ──────────────────────────────────────
async function updateRole(role: 'admin' | 'editor' | 'viewer') {
  await updateUserAttributes({
    userAttributes: {
      'custom:role': role,
      name:          'Updated Name',  // standard attributes use no prefix
    },
  })
}

// ── Sign in and get attributes in one flow ────────────────────────
async function signInAndGetProfile(email: string, password: string) {
  const signInOutput = await signIn({ username: email, password })

  if (signInOutput.isSignedIn) {
    const attrs   = await fetchUserAttributes()
    const session = await fetchAuthSession()

    return {
      userId:  attrs.sub,
      email:   attrs.email,
      groups:  (session.tokens?.idToken?.payload?.['cognito:groups'] as string[]) ?? [],
      expires: session.tokens?.accessToken.payload.exp as number,
    }
  }

  // Handle MFA or new password required
  return { nextStep: signInOutput.nextStep }
}

All Cognito attribute values come back as strings — even email_verified is the string "true", not the boolean true. Custom attributes must be declared in the Cognito User Pool schema before they can be set; in Amplify Gen 2, declare them in amplify/auth/resource.ts using defineAuth({'({ loginWith: ..., userAttributes: { "custom:role": { dataType: "String", mutable: true } } })'}). Never trust client-side attribute values for authorization decisions in backend code — always verify the JWT signature on the server and extract claims from the verified token.

TypeScript Code Generation for Typed JSON Responses

Amplify Gen 2's code generation pipeline turns a.model() schema definitions into TypeScript interfaces, GraphQL operation strings, and React hooks. The generated types live in src/API.ts and ensure your JSON responses are typed end-to-end — from the DynamoDB table definition through the AppSync resolver to the TypeScript component. Running npx ampx generate (or the sandbox watcher) regenerates these files whenever the schema changes.

// ── npx ampx sandbox  →  generates these files ───────────────────
//   src/API.ts               — TypeScript types for all models
//   src/graphql/queries.ts   — GraphQL query strings
//   src/graphql/mutations.ts — GraphQL mutation strings
//   src/graphql/subscriptions.ts
//   amplify_outputs.json     — runtime config (API endpoints, Auth config, S3 bucket)

// ── Using generated types with the GraphQL client ─────────────────
import { generateClient }      from 'aws-amplify/data'
import type { Schema }         from '@/amplify/data/resource'
import { GraphQLQuery }        from '@aws-amplify/api'
import { ListTodosQuery }      from '@/API'   // generated type
import { listTodos }           from '@/graphql/queries'

const client = generateClient<Schema>()

// Strongly typed via the generated Schema generic
async function fetchTypedTodos() {
  // Option A: model shorthand — infers return type from Schema
  const { data } = await client.models.Todo.list({
    selectionSet: ['id', 'title', 'done', 'priority'],  // narrow the fields
  })
  // data is inferred as Array<Pick<Todo, 'id'|'title'|'done'|'priority'>>

  // Option B: raw GraphQL with explicit type annotation
  const response = await client.graphql<GraphQLQuery<ListTodosQuery>>({
    query: listTodos,
    variables: { limit: 100 },
  })
  // response.data.listTodos.items: Todo[]
  const items = response.data?.listTodos?.items ?? []
  return items
}

// ── SelectionSet — constrain returned fields ──────────────────────
import type { SelectionSet } from 'aws-amplify/data'

type TodoCard = SelectionSet<
  Schema['Todo']['type'],
  ['id', 'title', 'done', 'priority']
>
// TodoCard: { id: string; title: string; done: boolean; priority: ... }
// TypeScript error if you access .content or .tags — not in selection set

async function fetchTodoCards(): Promise<TodoCard[]> {
  const { data } = await client.models.Todo.list({
    selectionSet: ['id', 'title', 'done', 'priority'],
  })
  return data  // typed as TodoCard[]
}

// ── React integration — useEffect with typed state ────────────────
import { useState, useEffect } from 'react'

function TodoList() {
  const [todos, setTodos] = useState<Schema['Todo']['type'][]>([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    const sub = client.models.Todo.observeQuery().subscribe({
      next: ({ items }) => {
        setTodos(items)
        setLoading(false)
      },
    })
    return () => sub.unsubscribe()
  }, [])

  if (loading) return <p>Loading...</p>

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          {todo.done ? '✓' : '○'} {todo.title}
          <span className="badge">{todo.priority}</span>
        </li>
      ))}
    </ul>
  )
}

// ── Server-side rendering with Amplify (Next.js App Router) ───────
// Use the Amplify server-side utilities for SSR
import { createServerRunner }        from '@aws-amplify/adapter-nextjs'
import { generateServerClientUsingCookies } from '@aws-amplify/adapter-nextjs/api'
import { cookies }                   from 'next/headers'
import outputs                       from '@/amplify_outputs.json'

export const { runWithAmplifyServerContext } = createServerRunner({ config: outputs })

export async function GET() {
  const todos = await runWithAmplifyServerContext({
    nextServerContext: { cookies },
    async operation(contextSpec) {
      const serverClient = generateServerClientUsingCookies<Schema>({
        config:  outputs,
        cookies,
      })
      const { data } = await serverClient.models.Todo.list()
      return data
    },
  })

  return Response.json(todos)
}

The selectionSet option on all model operations narrows the TypeScript return type to exactly the fields requested. This prevents accidentally accessing fields that were not fetched from the API, which would return undefined at runtime but would otherwise appear valid to TypeScript. For Next.js App Router SSR, use generateServerClientUsingCookies and runWithAmplifyServerContext — the regular browser client does not work in Node.js server context because it relies on browser cookie APIs. The server client reads the Cognito session from the cookie set by Amplify's auth UI components.

Key Terms

a.model()
The Amplify Gen 2 schema builder function that defines a data model in amplify/data/resource.ts. A single a.model() call provisions a DynamoDB table, an AppSync GraphQL API with auto-generated resolvers for CRUD and list operations, and TypeScript type definitions derived from the field declarations. Field methods like a.string(), a.integer(), a.boolean(), and a.json() map directly to JSON types in the GraphQL and REST responses. The .authorization() chained method translates to AppSync resolver-level access control rules, controlling which Cognito users or groups can read and write each model's JSON records.
DataStore
Amplify DataStore is an offline-first data layer that maintains a local SQLite database synchronized with DynamoDB via AppSync. It serializes all model instances to JSON before persisting locally and syncs mutations using a GraphQL subscriptions channel and delta-sync queries. DataStore provides 3 conflict resolution strategies for handling concurrent JSON mutations: Auto Merge for non-conflicting field updates, Optimistic Concurrency using a _version integer for last-write-wins, and Custom Lambda for business-logic-driven conflict resolution. DataStore is available in Amplify JS Library v5 and v6 and works identically on React, React Native, and plain JavaScript applications.
AppSync
AWS AppSync is the managed GraphQL API service that Amplify provisions when you define models with a.model(). AppSync handles the GraphQL execution layer — it receives GraphQL requests as JSON (the query, variables, and auth token), resolves each field against DynamoDB, and returns a JSON response matching the GraphQL schema. AppSync supports 4 request types: queries (read), mutations (write), subscriptions (real-time), and batch operations. All communication between the Amplify client and AppSync uses the GraphQL JSON wire format: requests are HTTP POST with a JSON body { query: "...", variables: {...} }, and responses are JSON { data: {...}, errors: [...] }.
amplify_outputs.json
A generated JSON configuration file produced by npx ampx sandbox or npx ampx pipeline-deploy that contains all runtime configuration values for an Amplify application. The file includes the AppSync GraphQL endpoint URL, Cognito User Pool ID and App Client ID, S3 bucket name and region, REST API Gateway endpoints, and authentication configuration. It is the single file passed to Amplify.configure(outputs) at application startup. The file must not be committed with real production values in public repositories; use environment-specific deployments and the Amplify console to manage backend environments.
SelectionSet
The SelectionSet TypeScript utility type from aws-amplify/data narrows a model type to only the fields specified in the selectionSet array. Passing selectionSet: ['id', 'title'] to client.models.Todo.list() reduces the JSON response payload to only those 2 fields and changes the TypeScript return type to { id: string; title: string }[]. This prevents accidental access of unresolved fields at the TypeScript level and reduces the network payload size — useful for list views where not every field of a large model is needed. The generated SelectionSet<Schema['ModelName']['type'], ['field1', 'field2']> type can be used in component props to enforce which fields were fetched.
Cognito user attributes
Cognito user attributes are key-value string pairs stored in the Cognito User Pool alongside each user record. Standard attributes (email, name, phone_number, sub) follow the OpenID Connect standard; custom attributes are prefixed with custom: and must be declared in the User Pool schema before use. Amplify returns them via fetchUserAttributes() as a Record<string, string> JSON object — all values are strings regardless of the declared data type. The sub attribute (UUID) is the immutable unique identifier and should be used as the foreign key when associating DynamoDB records with users. Attribute values are embedded in the Cognito ID token JWT payload, making them accessible server-side by verifying and decoding the JWT without an additional API call.

FAQ

How do I make a GraphQL query and get JSON data in Amplify?

Use client.models.ModelName.list() from generateClient<Schema>() in aws-amplify/data. The method returns a Promise&lt;{'{ data: T[], errors?: ... }'}> where T is inferred from the Schema generic. The response is a typed JSON object whose shape matches your a.model() definition exactly — no manual type annotation needed. For custom queries, import the generated query string from src/graphql/queries.ts and call client.graphql({'({ query: listTodos, variables: { limit: 100 } })'}). Amplify Gen 2 generates both the operation strings and the TypeScript types during npx ampx sandbox. Paginated list responses include a nextToken cursor field — pass it back as a variable to fetch the next page, with a default page size of 100 items. Always check for the errors field on the response before accessing data, as GraphQL allows partial success where some fields resolve and others do not.

How does Amplify DataStore sync JSON data offline?

DataStore writes all mutations to a local SQLite database first and immediately returns to the caller — the operation does not wait for network availability. When the device comes online, DataStore replays the local mutation queue in order to DynamoDB via AppSync, applying mutations as GraphQL operations. The sync queue is durable: mutations survive app restarts and are retried until they succeed or are rejected by the server's conflict resolver. Real-time sync uses a GraphQL subscription channel that streams mutations from other clients and applies them to the local SQLite store, keeping all devices consistent. The delta-sync mechanism fetches only records whose _lastChangedAt timestamp is newer than the device's last sync timestamp — meaning a reconnected device does not re-download the entire dataset but only the changed JSON records. For apps with many users writing the same records concurrently, configure the conflict resolution strategy in amplify/data/resource.ts; Auto Merge handles the majority of real-world cases by merging non-overlapping field changes.

How do I call a REST API and parse the JSON response in Amplify?

Import get, post, put, del from aws-amplify/api and pass the API name and path. The API name is the resource name from amplify_outputs.json. Call await restOperation.response to get the HttpResponse object, then call await response.body.json() to parse the response body as JSON. The returned type is Record<string, unknown> — validate it with Zod before use: const result = MySchema.safeParse(json). For POST requests, pass body: payloadObject in the options — Amplify serializes the object to JSON and sets the Content-Type: application/json header automatically. Check response.statusCode before parsing — a 4xx response has a JSON error body, not the success shape. The Amplify REST client automatically attaches the Cognito access token as a Bearer token in the Authorization header when the user is signed in, so you do not need to manage tokens manually.

How do I store a JSON file in S3 with Amplify?

Import uploadData from aws-amplify/storage. Convert your JavaScript object to a JSON string with JSON.stringify(payload) and pass it as the data parameter along with contentType: 'application/json'. Await the .result property to wait for the upload to complete. For files over 5 MB, Amplify automatically uses S3 multipart upload — pass an onProgress callback to track progress. To retrieve, call downloadData({'({ path: key })'}).result and then await result.body.text() followed by JSON.parse(). Use path prefixes to control access: public/ for world-readable files, protected/{identityId}/ for user-owned but publicly readable files, and private/{identityId}/ for files only the owner can access. The identityId comes from (await fetchAuthSession()).identityId. S3 JSON files are ideal for report exports, bulk data snapshots, and JSON configuration files that exceed DynamoDB's 400 KB single-item limit.

How do I get typed JSON responses from Amplify's GraphQL API?

Call generateClient<Schema>() with the Schema type exported from amplify/data/resource.ts. Every method on client.models returns a fully typed JSON response — client.models.Todo.list() returns { data: Todo[] } where Todo is inferred from the a.model() definition. For custom query strings, import the generated type from src/API.ts and annotate: client.graphql&lt;GraphQLQuery&lt;ListTodosQuery&gt;&gt;({'({ query: listTodos })'}). Use the selectionSet option to constrain which fields are fetched — this both reduces the JSON response size and narrows the TypeScript return type: client.models.Todo.list({'({ selectionSet: ["id", "title"] })'}) returns { id: string; title: string }[] rather than the full Todo type. Re-run npx ampx generate after every schema change to keep the generated types in sync.

What is the difference between Amplify GraphQL and REST for JSON APIs?

Amplify GraphQL is schema-first: you define models with a.model() and Amplify generates the DynamoDB tables, AppSync resolvers, and TypeScript types automatically — every JSON response has compile-time types because codegen derives them from the schema. The GraphQL client also powers DataStore offline sync, real-time observeQuery() subscriptions, and fine-grained @auth authorization rules. Amplify REST wraps API Gateway endpoints you define manually — the JSON response shape is unknown at build time, so responses are typed as Record<string, unknown> and require runtime Zod validation. Use GraphQL for standard CRUD on Amplify-managed data models where you want typed responses and 0 boilerplate. Use REST when connecting to existing API Gateway endpoints, Lambda functions with custom logic, third-party backends, or any endpoint where the response shape is not controlled by Amplify's schema. Both clients coexist in a single app and both automatically inject Cognito auth tokens — the 2 approaches are complementary rather than mutually exclusive.

How do I access user JSON attributes in Amplify Auth?

Call fetchUserAttributes() from aws-amplify/auth after sign-in. The return value is a flat Record<string, string> JSON object — all values are strings. Standard Cognito attributes include sub (the unique UUID identifier), email, name, phone_number, and email_verified. Custom attributes defined in the Cognito User Pool schema are accessed with the custom: prefix: attrs['custom:role']. For the raw JWT payload — which contains all claims including Cognito group membership as a cognito:groups string array — call fetchAuthSession() and access session.tokens?.idToken?.payload. Cognito groups are the correct mechanism for role-based access control: add users to Cognito groups in the console or via Admin SDK, then check payload['cognito:groups'] in your backend Lambda or AppSync resolvers. Never make server-side authorization decisions based on client-side attribute values without verifying the JWT signature first.

How do I validate Amplify REST API JSON responses with Zod?

Define a Zod schema matching the expected response shape, call response.body.json() to get the parsed JSON as unknown, then pass it to MySchema.safeParse(json). Check result.success before accessing result.data — if false, result.error.flatten().fieldErrors gives a field-level map of validation failures useful for logging and debugging. Build a typed wrapper function that accepts the API name, path, and a Zod schema and returns the validated type: this pattern catches 3 categories of failure in one place — network errors (the await throws), non-200 status codes (check response.statusCode), and schema mismatches (safeParse returns success: false). Use the wrapper as a queryFn in TanStack Query's useQuery — the validated, typed data flows into the component's props without any as type assertions. Re-run safeParse whenever the API version changes to catch breaking response shape changes immediately.

Format and validate Amplify JSON responses instantly

Paste any JSON from your Amplify GraphQL or REST API into Jsonic's formatter to inspect, validate, and generate TypeScript interfaces automatically.

Open JSON Formatter

Further reading and primary sources