> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livepeer.org/llms.txt
> Use this file to discover all available pages before exploring further.

# AI Image Generation App

> Build and deploy a Next.js image generation app calling Livepeer AI. Server action, form, history, ~30 minutes.

export const CenteredContainer = ({children, maxWidth = "800px", padding = "0", preset = "default", width = "", minWidth = "", marginRight = "", marginBottom = "", textAlign = "", style = {}, className = "", ...rest}) => {
  const presets = {
    default: {},
    fitContent: {
      width: "fit-content",
      minWidth: "fit-content"
    },
    readable70: {
      width: "70%",
      minWidth: "fit-content"
    },
    readable80: {
      width: "80%",
      minWidth: "fit-content"
    },
    readable90: {
      width: "90%"
    },
    wide900: {
      maxWidth: "900px"
    }
  };
  const presetStyle = presets[preset] || presets.default;
  return <div className={className} style={{
    maxWidth: presetStyle.maxWidth || maxWidth,
    margin: "0 auto",
    padding: padding,
    ...presetStyle.width ? {
      width: presetStyle.width
    } : {},
    ...presetStyle.minWidth ? {
      minWidth: presetStyle.minWidth
    } : {},
    ...width ? {
      width
    } : {},
    ...minWidth ? {
      minWidth
    } : {},
    ...marginRight ? {
      marginRight
    } : {},
    ...marginBottom ? {
      marginBottom
    } : {},
    ...textAlign ? {
      textAlign
    } : {},
    ...style
  }} {...rest}>
      {children}
    </div>;
};

export const CustomDivider = ({color = "var(--lp-color-border-default)", middleText = "", spacing = "default", style = {}, className = "", ...rest}) => {
  const spacingPresets = {
    default: {
      margin: "24px 0"
    },
    overlap: {
      margin: "-1rem 0 -1rem 0"
    },
    tight: {
      margin: "0 0 -1rem 0"
    },
    section: {
      margin: "0 0 -2rem 0"
    },
    sectionOverlap: {
      margin: "-1rem 0 -2rem 0"
    },
    deepOverlap: {
      margin: "-1rem 0 -1.5rem 0"
    }
  };
  const spacingStyle = spacingPresets[spacing] || spacingPresets.default;
  return <div role="separator" aria-orientation="horizontal" className={className} style={{
    display: "flex",
    alignItems: "center",
    ...spacingStyle,
    fontSize: style?.fontSize || "16px",
    height: "fit-content",
    ...style
  }} {...rest}>
      <span style={{
    marginRight: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
      </span>
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      {middleText && <>
          <Icon icon="circle" size={2} />
          <span style={{
    margin: "0 8px",
    fontWeight: "bold",
    color: color,
    opacity: 0.7
  }}>
            {middleText}
          </span>
          <Icon icon="circle" size={2} />
        </>}
      <div style={{
    flex: 1,
    height: "1px",
    background: "var(--lp-color-border-default)",
    opacity: 0.4
  }}></div>
      <span style={{
    marginLeft: "var(--lp-spacing-px-8)",
    opacity: 0.2
  }}>
        <span style={{
    display: "inline-block",
    transform: "scaleX(-1)"
  }}>
          <Icon icon="/snippets/assets/logos/Livepeer-Logo-Symbol-Theme.svg" />
        </span>
      </span>
    </div>;
};

export const LinkArrow = ({href, label, description, newline = true, borderColor, className = '', style = {}, ...rest}) => {
  const linkArrowStyle = {
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    gap: "var(--lp-spacing-1)",
    width: 'fit-content',
    ...borderColor && ({
      borderColor
    })
  };
  return <span className={className} style={style} {...rest}>
      {newline && <br />}
      <span style={linkArrowStyle}>
        <a href={href} target="_blank" rel="noopener noreferrer">
          {label}
        </a>
        <Icon icon="arrow-up-right" size={14} color="var(--lp-color-accent)" />
      </span>
      {description && description}
      {description && <div style={{
    height: "var(--lp-spacing-3)"
  }} />}
    </span>;
};

<CenteredContainer preset="readable90">
  <Tip>Wrap the AI Jobs API in a Next.js 15 app with a form, a server action, and a gallery. Thirty minutes from `create-next-app` to deployable.</Tip>
</CenteredContainer>

***

By the end of this tutorial you'll have a deployable Next.js 15 application that takes a text prompt, calls the Livepeer AI network through a server action, and displays the generated image alongside a history of past generations. The path uses Next.js 15's App Router and server actions, the `@livepeer/ai` TypeScript SDK, and the free Cloud Community Gateway from the <LinkArrow href="/v2/developers/build/ai-and-agents/ai-jobs-direct-quickstart" label="AI Jobs Direct Quickstart" newline={false} />.

This is the Persona 1 builder activation moment: the AI Jobs Quickstart proved the API call works; this tutorial proves it ships. Replace the community Gateway with a paid one when you deploy to users.

<CustomDivider />

## Required Tools

* Node.js 20 or later
* `npm`, `pnpm`, or `yarn`
* A code editor

No API key needed for development. The community Gateway accepts unauthenticated POSTs for experimentation; production deployments use a paid Gateway with a Bearer token.

<CustomDivider />

## Project Bootstrap

<Steps>
  <Step title="Create the Next.js project">
    ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    npx create-next-app@latest livepeer-image-gen \
      --typescript \
      --tailwind \
      --app \
      --src-dir \
      --import-alias "@/*"
    cd livepeer-image-gen
    ```

    The flags request the App Router (`--app`), a `src/` layout, TypeScript, Tailwind, and the `@/*` import alias. Accept the default for any remaining prompts.
  </Step>

  <Step title="Install the Livepeer AI SDK">
    ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    npm install @livepeer/ai
    ```

    The package wraps the AI Jobs REST surface with TypeScript types and handles request shaping for all eleven native pipelines.
  </Step>

  <Step title="Configure environment variables">
    Save as `.env.local`:

    ```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    LIVEPEER_GATEWAY_URL=https://dream-gateway.livepeer.cloud
    ```

    The community Gateway is fine for development. For production, swap to a paid Gateway URL and add `LIVEPEER_API_KEY` for Bearer authentication.
  </Step>
</Steps>

<CustomDivider />

## Server Action

The server action runs server-side, which means the Gateway call happens on your Next.js host instead of the browser. This keeps any future API key out of client-side bundles and centralises retry logic.

<Steps>
  <Step title="Create the action file">
    Save as `src/app/actions.ts`:

    ```ts theme={"theme":{"light":"github-light","dark":"dark-plus"}}
    'use server';

    import { Livepeer } from '@livepeer/ai';

    const livepeer = new Livepeer({
      serverURL: process.env.LIVEPEER_GATEWAY_URL!,
    });

    export interface GenerationResult {
      url: string;
      seed: number;
      nsfw: boolean;
      prompt: string;
      timestamp: number;
    }

    export async function generateImage(formData: FormData): Promise<{
      success: boolean;
      result?: GenerationResult;
      error?: string;
    }> {
      const prompt = formData.get('prompt') as string;
      const modelId = (formData.get('modelId') as string) || 'ByteDance/SDXL-Lightning';

      if (!prompt || prompt.trim().length < 3) {
        return { success: false, error: 'Prompt must be at least 3 characters.' };
      }

      try {
        const response = await livepeer.generate.textToImage({
          modelId,
          prompt,
          width: 1024,
          height: 1024,
        });

        const image = response.imageResponse?.images[0];
        if (!image?.url) {
          return { success: false, error: 'No image returned from gateway.' };
        }

        return {
          success: true,
          result: {
            url: image.url,
            seed: image.seed ?? 0,
            nsfw: image.nsfw ?? false,
            prompt,
            timestamp: Date.now(),
          },
        };
      } catch (err) {
        const message = err instanceof Error ? err.message : 'Unknown error';
        return { success: false, error: `Gateway call failed: ${message}` };
      }
    }
    ```

    The `'use server'` directive at the top marks every export as a server action. Client components can import and call `generateImage` as if it were a local function; Next.js handles the network round trip.
  </Step>
</Steps>

The action validates the prompt, calls the Gateway, and returns a typed result. Validation runs server-side, which means client-side bypass isn't possible.

<CustomDivider />

## UI Form Component

Save as `src/app/components/PromptForm.tsx`:

```tsx theme={"theme":{"light":"github-light","dark":"dark-plus"}}
'use client';

const { useState } = React;
const { useFormStatus } = ReactDOM;
// Import generateImage and GenerationResult from ../actions.

const MODELS = [
  { id: 'ByteDance/SDXL-Lightning', label: 'SDXL-Lightning (fast)' },
  { id: 'SG161222/RealVisXL_V4.0_Lightning', label: 'RealVisXL (photoreal)' },
];

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button
      type="submit"
      disabled={pending}
      className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
    >
      {pending ? 'Generating…' : 'Generate'}
    </button>
  );
}

export function PromptForm({
  onResult,
}: {
  onResult: (result: GenerationResult) => void;
}) {
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(formData: FormData) {
    setError(null);
    const response = await generateImage(formData);
    if (response.success && response.result) {
      onResult(response.result);
    } else {
      setError(response.error ?? 'Generation failed.');
    }
  }

  return (
    <form action={handleSubmit} className="space-y-4">
      <textarea
        name="prompt"
        required
        rows={3}
        placeholder="A coffee shop at sunset, photorealistic"
        className="w-full border rounded p-2"
      />
      <select name="modelId" className="border rounded p-2">
        {MODELS.map((m) => (
          <option key={m.id} value={m.id}>
            {m.label}
          </option>
        ))}
      </select>
      <SubmitButton />
      {error && <p className="text-red-600">{error}</p>}
    </form>
  );
}
```

`useFormStatus` reads the submission state of the parent form, which gives the button a `pending` flag without any extra wiring. The `MODELS` array maps to the warm models verified in the <LinkArrow href="/v2/developers/build/ai-and-agents/model-support" label="Model Support" newline={false} /> reference.

<CustomDivider />

## Image Gallery

The gallery holds generation history in client state. For production, swap the in-memory state for a database or local storage.

Save as `src/app/components/Gallery.tsx`:

```tsx theme={"theme":{"light":"github-light","dark":"dark-plus"}}
'use client';

import Image from 'next/image';
// Import GenerationResult from ../actions.

export function Gallery({ items }: { items: GenerationResult[] }) {
  if (items.length === 0) {
    return <p className="text-gray-500">No generations yet.</p>;
  }

  return (
    <div className="grid grid-cols-2 gap-4">
      {items.map((item) => (
        <div key={item.timestamp} className="border rounded p-2">
          <Image
            src={item.url}
            alt={item.prompt}
            width={512}
            height={512}
            unoptimized
            className="rounded"
          />
          <p className="text-sm mt-2 line-clamp-2">{item.prompt}</p>
          <p className="text-xs text-gray-500">Seed: {item.seed}</p>
        </div>
      ))}
    </div>
  );
}
```

`unoptimized` on the `<Image>` component skips Next.js's image optimisation, which is necessary because the Gateway returns URLs Next.js can't proxy through its own `_next/image` endpoint without remote-pattern configuration.

To allow Next.js optimisation instead, add the Gateway host to `next.config.ts`:

```ts theme={"theme":{"light":"github-light","dark":"dark-plus"}}
import type { NextConfig } from 'next';

const config: NextConfig = {
  images: {
    remotePatterns: [
      { protocol: 'https', hostname: 'dream-gateway.livepeer.cloud' },
    ],
  },
};

export default config;
```

<CustomDivider />

## Page Composition

Save as `src/app/page.tsx`:

```tsx theme={"theme":{"light":"github-light","dark":"dark-plus"}}
'use client';

const { useState } = React;
// Import PromptForm from ./components/PromptForm.
// Import Gallery from ./components/Gallery.
// Import GenerationResult from ./actions.

export default function HomePage() {
  const [items, setItems] = useState<GenerationResult[]>([]);

  return (
    <main className="max-w-4xl mx-auto p-8 space-y-8">
      <header>
        <h1 className="text-3xl font-bold">Image Generation</h1>
        <p className="text-gray-600">
          Powered by Livepeer AI on the Cloud Community Gateway.
        </p>
      </header>
      <PromptForm onResult={(r) => setItems((prev) => [r, ...prev])} />
      <Gallery items={items} />
    </main>
  );
}
```

Run the dev server:

```bash theme={"theme":{"light":"github-light","dark":"dark-plus"}}
npm run dev
```

Open `http://localhost:3000`. Type a prompt, click **Generate**, and the gallery populates with your first image.

<CustomDivider />

## Production Considerations

The community Gateway is shaped for experimentation. Production deployments need four changes.

**One. Switch to a paid Gateway.** Set `LIVEPEER_GATEWAY_URL` to a paid Gateway endpoint and add `LIVEPEER_API_KEY` to the environment. Update the SDK initialisation to pass the key in the auth header.

**Two. Rate-limit per user.** Server actions run on your Next.js host; a malicious client can submit the form in a tight loop. Add a per-IP or per-session limit using a Redis backend or a serverless rate-limit library.

**Three. Cache and persist.** Generation costs money. Cache results by `(prompt, modelId, seed)` and persist gallery state in a database so users keep their history across sessions.

**Four. Move large images out of the response path.** The Gateway returns a hosted URL; download and rehost on your own CDN for control over availability and retention.

Full hardening guidance in <LinkArrow href="/v2/developers/guides/production-hardening-checklist" label="Production Hardening Checklist" newline={false} />.

<CustomDivider />

## Common Errors

<AccordionGroup>
  <Accordion title="`@livepeer/ai` import fails after install">
    The package needs Node 20 or later for the `fetch` polyfill it relies on. Confirm `node --version` and upgrade if needed.
  </Accordion>

  <Accordion title="`Gateway call failed: No orchestrator available`">
    No Orchestrator on the network has capacity for the requested model right now. Retry, or fall back to the other warm model from the `MODELS` array.
  </Accordion>

  <Accordion title="Image renders broken in the gallery">
    Either `unoptimized` is missing from `<Image>`, or the Gateway host is missing from `next.config.ts` `remotePatterns`. Pick one path.
  </Accordion>

  <Accordion title="Server action returns 405 in production">
    Server actions need a runtime that supports them. Vercel, Cloudflare, and Node servers all work; static-only hosts (GitHub Pages, plain S3) don't. Deploy to a runtime host.
  </Accordion>

  <Accordion title="Cold-start delay on first request">
    The first request to a cold model triggers a 30-second to multi-minute load. Add a loading state in the UI and consider warming the model on app start by sending a dummy request.
  </Accordion>
</AccordionGroup>

<CustomDivider />

You have a deployed Next.js app generating images through Livepeer AI. The same server action pattern works for all nine batch pipelines; swap the endpoint and request body to add image-to-video, audio-to-text, or any other pipeline.

## AI agent prompt

```text theme={"theme":{"light":"github-light","dark":"dark-plus"}}
Build the "AI Image Generation App" tutorial as a Next.js App Router project. Create a new app with TypeScript, Tailwind, ESLint, and src/app, install @livepeer/ai, add LIVEPEER_GATEWAY_URL=https://dream-gateway.livepeer.cloud to .env.local, and implement a server action that calls the Livepeer text-to-image endpoint with model_id "SG161222/RealVisXL_V4.0_Lightning". Build a prompt form, client gallery, and page composition exactly as a runnable app. Include error handling for empty prompts, gateway failures, and missing images. Run npm run dev and verify http://localhost:3000 can generate and display an image. Do not require a Livepeer Studio API key for development; keep any production key server-side only.
```

<CustomDivider />

## Next Steps

<CardGroup cols={2}>
  <Card title="AI Pipelines" icon="layer-group" href="/v2/developers/build/ai-and-agents/ai-pipelines">
    The other ten pipelines: LLM, audio-to-text, image-to-video, segmentation, and more.
  </Card>

  <Card title="Model Support" icon="cube" href="/v2/developers/build/ai-and-agents/model-support">
    Warm models, VRAM requirements, custom-model paths.
  </Card>

  <Card title="Chatbot Tutorial" icon="message" href="/v2/developers/build/tutorials/build-a-chatbot-with-livepeer-llm">
    Build a streaming chat app on the LLM pipeline.
  </Card>

  <Card title="Production Hardening" icon="shield" href="/v2/developers/guides/production-hardening-checklist">
    Rate limits, caching, auth, observability before ship.
  </Card>
</CardGroup>
