<!-- AI agents: this is the Markdown version of an AppNorth documentation page. Index: https://www.appnorth.app/llms.txt -->

# Build a custom UI

Use `AppNorthClient` when your app needs its own list, search, or composer. It handles HTTP encoding, project identifiers, identity headers, and response decoding. Your UI owns loading, error, retry, and pagination states.

## Load boards and ideas {#read}

Read boards to obtain their slugs, statuses, and participation policies. Search uses `query`; status filtering uses the server's status slug.

```swift title="CommunityQueries.swift"
import AppNorth

func searchIdeas(client: AppNorthClient, board: String, text: String) async throws -> AppNorthPage<AppNorthIdea> {
    try await client.ideas(board: board, query: text, sort: .recent, limit: 20)
}

func comments(client: AppNorthClient, idea: AppNorthIdea) async throws -> AppNorthPage<AppNorthComment> {
    try await client.comments(for: idea.id)
}
```

`idea(_:)`, comments, votes, and comment creation accept an idea ID or slug. Use the returned `AppNorthIdea` rather than constructing response models: most response types have no public memberwise initializer.

## Load the next page {#pagination}

Pass `nextCursor` back unchanged. Stop when it is `nil`, and reset the cursor when filters or sorting change. The client clamps page sizes to 1–50, with a default of 20.

```swift title="NextIdeaPage.swift"
import AppNorth

func nextPage(
    client: AppNorthClient,
    board: String,
    previous: AppNorthPage<AppNorthIdea>
) async throws -> AppNorthPage<AppNorthIdea>? {
    guard let cursor = previous.nextCursor else { return nil }
    return try await client.ideas(board: board, sort: .recent, cursor: cursor)
}
```

Use the same board, sort, search, and status on every page of one query. Discard responses from an old query or account after the user changes context.

## Submit and retry {#writes}

Create one UUID per user intention and retain it until that operation succeeds or is abandoned. Pass that same key on retry; a new key can create another item.

```swift title="SubmitIdea.swift"
import AppNorth

enum CommunitySubmissionError: Error { case notAllowed }

func submitIdea(
    client: AppNorthClient,
    board: AppNorthBoard,
    title: String,
    description: String,
    retryKey: String
) async throws -> AppNorthCreation<AppNorthIdea> {
    guard client.canParticipate(board.policies.submission) else {
        throw CommunitySubmissionError.notAllowed
    }
    return try await client.createIdea(
        board: board.slug,
        title: title,
        description: description,
        idempotencyKey: retryKey
    )
}
```

Handle `CommunitySubmissionError.notAllowed` with your app’s sign-in or disabled state. `canParticipate` checks local policy and expiry; only the server verifies the signature.

`setVote(on:voted:)` expresses the desired state. Render its returned `voted` and `voteCount`, and use `applied` to distinguish a change from a repeated request. `createComment(on:body:authorName:idempotencyKey:)` returns a creation result with the same moderation flag as ideas.

## Handle failures {#errors}

Catch `AppNorthError.server(status:code:fields:)` to distinguish identity failures, validation errors, and rate limits. Keep the entered text on failure. Do not automatically retry every 4xx response; renew an expired identity through your authenticated backend first. See [errors](/docs/errors) and [offline writes](/docs/offline-and-cache).

## Verify {#verify}

1. Load a board and an idea from its response, then retrieve that idea's comments.
2. Verify pagination using actual `nextCursor` values, including the final `nil` cursor.
3. Retry the same creation with the same key and unchanged payload; confirm only one item exists.
4. Test disabled and identified policies, moderated submissions, and a failed request without losing the draft.
