---
title: "Payload CMS Android Integration: Next.js to Kotlin Guide"
slug: "connect-nextjs-payload-cms-android-kotlin"
published: "2026-05-29"
updated: "2026-06-03"
validated: "2026-06-03"
categories:
  - "Payload"
tags:
  - "Payload CMS Android"
  - "Next.js Payload CMS"
  - "Kotlin Retrofit OkHttp"
  - "PersistentCookieJar"
  - "Payload custom endpoints"
  - "shared service layer"
  - "Payload Local API"
  - "polymorphic relations"
  - "token refresh authenticator"
  - "Android TV integration"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js"
  - "payload cms v3"
  - "kotlin"
  - "retrofit"
  - "okhttp"
status: "stable"
llm-purpose: "Payload CMS Android integration: step-by-step guide to share Next.js services with Kotlin clients using Retrofit, OkHttp, persistent cookies and…"
llm-prereqs:
  - "Access to Next.js"
  - "Access to Payload CMS v3"
  - "Access to Kotlin"
  - "Access to Retrofit"
  - "Access to OkHttp"
llm-outputs:
  - "Completed outcome: Payload CMS Android integration: step-by-step guide to share Next.js services with Kotlin clients using Retrofit, OkHttp, persistent cookies and…"
---

**Summary Triples**
- (Payload CMS v3, exposes, a native REST API, supports custom endpoints, and provides a Local API that runs in-process with Next.js)
- (Business logic, should be implemented as, transport-agnostic TypeScript service functions that both web and mobile adapters call)
- (Next.js, acts as, the transport/adapters layer (Server Actions, API routes) that maps HTTP requests to shared services)
- (Android client, uses, Retrofit + OkHttp with a PersistentCookieJar to reuse server sessions)
- (OkHttp, provides, Authenticator interface to implement token refresh and request retry logic)
- (Polymorphic relations, require, explicit client-side mapping and DTOs (use Gson type adapters or explicit model fields))
- (Sensitive operations (e.g., join request), should be exposed via, server-side custom endpoints that call shared services and perform validation)
- (Payload Local API, allows, Next.js server code to invoke the same services without an HTTP round-trip)
- (Integration testing, should include, endpoint smoke tests (cURL/Postman), service unit tests and Android instrumentation tests)
- (Android TV, reuses, the same service layer and network client; only the presentation and input handling change)
- (PersistentCookieJar, keeps, session cookies across app restarts so server sessions remain valid)
- (Security, requires, server-side input validation, session verification, and secure cookie/JWT handling)

### {GOAL}
Payload CMS Android integration: step-by-step guide to share Next.js services with Kotlin clients using Retrofit, OkHttp, persistent cookies and…

### {PREREQS}
- Access to Next.js
- Access to Payload CMS v3
- Access to Kotlin
- Access to Retrofit
- Access to OkHttp

### {STEPS}
1. Design four-layer backend architecture
2. Implement shared service for joinEvent
3. Create Next.js web adapter (Server Action)
4. Expose Payload custom endpoint for mobile
5. Add persistent cookie storage on Android
6. Assemble OkHttp + Retrofit client
7. Implement token refresh Authenticator
8. Handle polymorphic JSON and queries

<!-- llm:goal="Payload CMS Android integration: step-by-step guide to share Next.js services with Kotlin clients using Retrofit, OkHttp, persistent cookies and…" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to Payload CMS v3" -->
<!-- llm:prereq="Access to Kotlin" -->
<!-- llm:prereq="Access to Retrofit" -->
<!-- llm:prereq="Access to OkHttp" -->
<!-- llm:output="Completed outcome: Payload CMS Android integration: step-by-step guide to share Next.js services with Kotlin clients using Retrofit, OkHttp, persistent cookies and…" -->

# Payload CMS Android Integration: Next.js to Kotlin Guide
> Payload CMS Android integration: step-by-step guide to share Next.js services with Kotlin clients using Retrofit, OkHttp, persistent cookies and…
Matija Žiberna · 2026-05-29

I was building a recreation booking platform for a sports club — a web app in Next.js with Payload CMS v3 for the backend, plus Android TV and mobile clients so users could browse and join events from their television. The web side was straightforward. Then came the Android integration.

The obvious approach would have been to build a separate REST API layer just for Android. What I discovered instead was that Payload CMS, sitting inside Next.js, already has everything you need: a native REST API, custom endpoint support, and a Local API that runs in the same Node process. The real challenge was figuring out how to share business logic between the web and mobile surfaces without duplicating it.

This guide documents the architecture I landed on and the specific implementation steps on both the TypeScript backend and Kotlin Android sides. It is a blueprint you can follow directly — including the pitfalls I ran into so you don't have to.

---

## 1. The 4-Layer Backend Architecture

Before writing a single line of Android code, the backend needs to be structured so that both the web frontend and the mobile client can consume the same underlying logic. The mistake I almost made was writing Android-specific service methods on the backend. The fix was recognizing that the business logic itself — validating a join request, checking capacity, creating an attendance record — does not care who is calling it. Only the transport layer cares.

The architecture I settled on has four layers with clear boundaries:

```mermaid
graph TD
    subgraph Client Layer
        Web[Web Frontend]
        Android[Android TV / Mobile App]
    end

    subgraph HTTP & Action Adapters
        SA[Next.js Server Actions]
        CAPI[Payload Custom Endpoints]
        NAPI[Payload Native REST API]
    end

    subgraph Domain Layer
        Service[Shared Services: src/payload/services/*]
        DB[Query Layer: src/payload/db/*]
    end

    subgraph Data Layer
        Payload[Payload Local API / DB]
    end

    Web -->|Calls| SA
    Android -->|HTTP GET/POST| CAPI
    Android -->|HTTP GET| NAPI
    SA -->|Invokes| Service
    CAPI -->|Invokes| Service
    CAPI -->|Reads| DB
    SA -->|Reads| DB
    Service -->|Mutates| Payload
    DB -->|Queries| Payload
```

The **Query Layer** (`src/payload/db/*`) centralizes all database reads. It abstracts Payload's Local API and stays server-only — no Next.js revalidation logic, no HTTP request parsing. It just fetches data and returns it.

The **Service Layer** (`src/payload/services/*`) is where all business logic and state mutations live. Services accept raw inputs plus the authenticated `User` actor. They throw structured domain errors — `ConflictServiceError`, `NotFoundServiceError` — and they never call `revalidatePath` or perform redirects. That is the adapter's job.

The **Web Adapters** are your Next.js Server Actions and page routes. They read data via the Query Layer, call services for mutations, catch domain errors to translate them into UI-friendly validation messages, and handle cache revalidation. They are intentionally thin.

The **HTTP Adapters** are Payload custom endpoints at `/api/mobile/*`. For simple read-only collection access the native Payload REST API is sufficient. Custom endpoints are for multi-step mutations, workflows, or reads that need server-side computed fields — like an event detail that includes a participant count.

The key insight that makes this architecture work: the Service Layer has no knowledge of who is calling it. A Server Action and a custom endpoint both call `joinEvent(eventId, actor)` with identical arguments. The service runs the same validation, creates the same records, throws the same errors. Only the adapter at the boundary differs.

---

## 2. Sharing Code Between Web and Android

With the architecture defined, let's walk through a concrete example: a user joining an event. This operation happens on the web when a player clicks a button on the event detail page, and on Android TV when they tap the same action in the app. The goal is that both paths run identical business logic without any duplication.

### Step 1: Write the Shared Service

The service is written once and knows nothing about HTTP or React. It takes the event ID and the authenticated user, validates the request, creates the attendance record, and keeps the event's relation array in sync.

```typescript
// src/payload/services/join-event.ts
export async function joinEvent(eventId: string | number, actor: User) {
  const event = await findEventByIdOrNull(eventId, { depth: 1, overrideAccess: true })
  if (!event) throw new NotFoundServiceError('Dogodek ni bil najden.')
  if (event.isLocked) throw new ConflictServiceError('Prijave so zaklenjene.')

  const payload = await getDb()
  
  const attendance = await payload.create({
    collection: 'attendances',
    data: { user: actor.id, event: event.id, status: 'PRESENT' },
    overrideAccess: true,
  })

  const existingRefs = event.relationships?.attendances?.map(a => typeof a === 'object' ? a.id : a) || []
  await payload.update({
    collection: 'events',
    id: event.id,
    data: {
      relationships: {
        ...event.relationships,
        attendances: [...existingRefs, attendance.id],
      }
    },
    overrideAccess: true,
  })

  return { attendanceId: attendance.id }
}
```

Notice that the explicit roster sync — fetching existing attendances and updating the event with the new ID appended — is intentional. Payload's Local API does not automatically back-populate relation arrays when you create a related record. If you skip this update step, the event will still show zero attendees even after the attendance record exists. More on this in the pitfalls section.

### Step 2: Adapt for Web (Server Action)

The web adapter wraps the service in a Server Action, adds authentication, and handles Next.js cache revalidation after the mutation succeeds. This is the only place where `revalidatePath` appears — keeping it out of the service layer means the service can be called from anywhere without triggering React cache behavior unexpectedly.

```typescript
// src/app/(frontend)/profil/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { requireAuthenticatedPlayerForAction } from '@/utilities/auth/getAuthenticatedPlayer'
import { joinEvent } from '@/payload/services'

export async function joinEventAction(eventId: string) {
  const player = await requireAuthenticatedPlayerForAction()
  await joinEvent(eventId, player.payloadUser)
  revalidatePath(`/rekreacije/${eventId}`)
  revalidatePath('/profil')
}
```

### Step 3: Adapt for Mobile (Custom Endpoint)

The mobile adapter registers the same service call behind a custom Payload endpoint. The only difference from the web adapter is how errors are communicated: instead of populating a form state object, they become HTTP status codes with a JSON body.

```typescript
// src/payload/endpoints/mobile.ts
{
  path: '/mobile/events/:id/join',
  method: 'post',
  handler: async (req) => {
    try {
      const actor = requirePlayerActor(req.user)
      const eventId = String(req.routeParams?.id ?? '')
      const result = await joinEvent(eventId, actor)
      return json(req, { ok: true, message: 'Uspešno prijavljeni.', data: result })
    } catch (error) {
      return handleServiceError(req, error)
    }
  }
}
```

The `handleServiceError` utility maps domain error subclasses to their corresponding HTTP status codes: `NotFoundServiceError` becomes 404, `ConflictServiceError` becomes 409, and so on. This mapping lives in one place, so every mobile endpoint benefits from it automatically.

With the backend in place, both surfaces call the same service, handle the same errors, and persist the same data. The Android client now needs to consume these endpoints, which brings us to the Kotlin implementation.

---

## 3. Android Client Implementation (Kotlin)

The Android apps use Retrofit for HTTP communication and OkHttp as the underlying client. Payload CMS authenticates users via a `payload-token` cookie, so the first thing the Android client needs is a mechanism to capture that cookie on login and replay it on every subsequent request.

### Step 1: Persistent Cookie Storage

When a player logs in, Payload's response includes a `Set-Cookie: payload-token=...` header. On subsequent requests, the app needs to send that cookie back. OkHttp's `CookieJar` interface handles this automatically — we just need to wire in a persistent implementation that survives app restarts by writing to `SharedPreferences`.

```kotlin
// android/app/src/main/java/com/rekreacija/player/data/api/CookieJar.kt
class PersistentCookieJar(context: Context) : CookieJar {
    private val sharedPrefs = context.getSharedPreferences("payload_cookies", Context.MODE_PRIVATE)

    @Synchronized
    override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
        val editor = sharedPrefs.edit()
        for (cookie in cookies) {
            if (cookie.name == "payload-token") {
                editor.putString("payload-token-value", cookie.value)
                editor.putString("payload-token-domain", cookie.domain)
                editor.putString("payload-token-path", cookie.path)
                editor.putLong("payload-token-expires", cookie.expiresAt)
            }
        }
        editor.apply()
    }

    @Synchronized
    override fun loadForRequest(url: HttpUrl): List<Cookie> {
        val value = sharedPrefs.getString("payload-token-value", null) ?: return emptyList()
        val domain = sharedPrefs.getString("payload-token-domain", url.host) ?: url.host
        val expires = sharedPrefs.getLong("payload-token-expires", 0L)

        if (expires > 0L && expires < System.currentTimeMillis()) {
            clear()
            return emptyList()
        }

        return listOf(Cookie.Builder()
            .name("payload-token")
            .value(value)
            .hostOnlyDomain(domain)
            .path(sharedPrefs.getString("payload-token-path", "/") ?: "/")
            .expiresAt(expires)
            .build()
        )
    }

    @Synchronized
    fun saveToken(token: String, domain: String) {
        sharedPrefs.edit()
            .putString("payload-token-value", token)
            .putString("payload-token-domain", domain)
            .putLong("payload-token-expires", System.currentTimeMillis() + 30 * 24 * 60 * 60 * 1000L)
            .apply()
    }

    @Synchronized
    fun clear() { sharedPrefs.edit().clear().apply() }
}
```

The expiry check in `loadForRequest` is important: it prevents the app from sending a known-expired token, which would trigger a 401 on every request until the user manually logs out and back in.

### Step 2: Assembling the OkHttp Client

With cookie storage handled, the next step is assembling the OkHttp client with three concerns layered together: dynamic base URL configuration, authentication header injection, and the token refresh authenticator.

**Dynamic base URL** is a common source of pain during Android development. Hardcoding a local development IP address into Kotlin is a reliable way to ship that IP to production. Use Gradle's `BuildConfig` to inject the value at build time instead:

```kotlin
// android/app/build.gradle.kts
android {
    buildTypes {
        release {
            buildConfigField("String", "BASE_URL", "\"https://api.rekreacija.com/\"")
        }
        debug {
            val devIp = project.findProperty("devIp") ?: "192.168.64.125"
            buildConfigField("String", "BASE_URL", "\"http://$devIp:3000/\"")
        }
    }
}
```

Developers set their own `devIp` in a local `gradle.properties` file that is gitignored. Release builds always point to the production URL.

**Authentication headers** are injected by an OkHttp interceptor. During this project's development, custom endpoints read from cookies while the native Payload REST API expected a Bearer token. Rather than rewrite the access layer mid-project, both headers are injected simultaneously. This is technical debt worth noting: in a greenfield project, standardizing on a single auth mechanism from the start will keep the backend cleaner.

**The finalized `NetworkModule`** brings all of this together as a singleton:

```kotlin
// android/app/src/main/java/com/rekreacija/player/data/api/NetworkModule.kt
object NetworkModule {
    private var apiService: ApiService? = null
    private var cookieJar: PersistentCookieJar? = null

    fun getCookieJar(context: Context): PersistentCookieJar {
        if (cookieJar == null) {
            cookieJar = PersistentCookieJar(context.applicationContext)
        }
        return cookieJar!!
    }

    fun getApiService(context: Context): ApiService {
        if (apiService == null) {
            val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY }
            val jar = getCookieJar(context)

            val authInterceptor = Interceptor { chain ->
                val request = chain.request()
                val token = context.getSharedPreferences("payload_cookies", Context.MODE_PRIVATE)
                    .getString("payload-token-value", null)

                val builder = request.newBuilder()
                if (token != null) {
                    builder.header("Authorization", "Bearer $token")
                    builder.header("Cookie", "payload-token=$token")
                }
                chain.proceed(builder.build())
            }

            val client = OkHttpClient.Builder()
                .cookieJar(jar)
                .addInterceptor(authInterceptor)
                .addInterceptor(logging)
                .connectTimeout(15, TimeUnit.SECONDS)
                .authenticator(TokenAuthenticator(context, { getApiService(context) }, jar))
                .build()

            apiService = Retrofit.Builder()
                .baseUrl(BuildConfig.BASE_URL)
                .client(client)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
                .create(ApiService::class.java)
        }
        return apiService!!
    }
}
```

The `TokenAuthenticator` passed here is covered in Step 5. For now, the important thing is that it is registered on the client — OkHttp will automatically call it whenever it receives a `401` response.

### Step 3: Retrofit Service Interface

With the client configured, Retrofit gives you a type-safe interface for declaring endpoints. The interface maps directly onto the three backend surfaces: native Payload REST for reads, custom mobile endpoints for mutations, and the auth endpoints Payload generates automatically:

```kotlin
// android/app/src/main/java/com/rekreacija/player/data/api/ApiService.kt
interface ApiService {
    @POST("api/players/login")
    suspend fun login(@Body request: LoginRequest): Response<LoginResponse>

    @POST("api/players/logout")
    suspend fun logout(): Response<GenericMobileResponse<Unit>>

    @POST("api/players/refresh-token")
    suspend fun refreshToken(): Response<LoginResponse>

    @POST("api/mobile/events/{id}/join")
    suspend fun joinEvent(@Path("id") eventId: String): Response<GenericMobileResponse<JoinEventResponseData>>

    @GET("api/mobile/events")
    suspend fun getEventsMobile(
        @Query("leagueId") leagueId: String?,
        @Query("after") after: String?
    ): Response<PayloadListResponse<Event>>

    @GET("api/locations")
    suspend fun getLocations(@Query("limit") limit: Int = 100): Response<PayloadListResponse<Location>>
}
```

Simple collection reads — locations, static reference data — go directly through the native API. The custom mobile endpoints handle anything that requires server-side computation or multi-step writes.

### Step 4: Parsing Error Responses from the Backend

By default, Retrofit treats any non-2xx response as a failed call. But the backend we built returns structured error payloads on 4xx responses — a `message` field with a user-readable string like "You are already registered for this event." The repository layer needs to extract that message instead of presenting a generic network error.

```kotlin
// Repository layer (e.g. EventRepository.kt)
suspend fun joinEvent(eventId: String): Result<JoinEventResponseData> = withContext(Dispatchers.IO) {
    try {
        val response = apiService.joinEvent(eventId)
        val body = response.body()

        if (response.isSuccessful && body?.ok == true && body.data != null) {
            Result.success(body.data)
        } else {
            var errorMsg = "Prijava na dogodek ni uspela."
            try {
                val errorBodyString = response.errorBody()?.string()
                if (!errorBodyString.isNullOrEmpty()) {
                    val errorMap = Gson().fromJson(errorBodyString, Map::class.java)
                    errorMsg = errorMap["message"] as? String ?: errorMsg
                }
            } catch (e: Exception) { }
            Result.failure(Exception(errorMsg))
        }
    } catch (e: Exception) {
        Result.failure(e)
    }
}
```

This pattern — wrapping the call, checking `response.isSuccessful`, and parsing `errorBody()` on failure — should be consistent across all repository methods. The `message` field populated on the backend flows directly to the UI without any transformation in between.

### Step 5: Automatic Token Refresh

Sessions eventually expire. Rather than redirecting users to a login screen the moment their token expires mid-session, OkHttp's `Authenticator` interface lets you intercept a `401`, refresh the token transparently, and retry the original request — all without the ViewModel or UI layer being aware it happened.

The authenticator runs synchronously, which means using `runBlocking` to make the refresh network call. The `synchronized` block prevents multiple concurrent requests from each triggering a separate refresh call simultaneously.

```kotlin
// android/app/src/main/java/com/rekreacija/player/data/api/TokenAuthenticator.kt
class TokenAuthenticator(
    private val context: Context,
    private val apiServiceLazy: () -> ApiService,
    private val cookieJar: PersistentCookieJar
) : Authenticator {

    override fun authenticate(route: Route?, response: Response): Request? {
        if (response.priorResponse != null) {
            return null
        }

        synchronized(this) {
            val sharedPrefs = context.getSharedPreferences("payload_cookies", Context.MODE_PRIVATE)
            val currentToken = sharedPrefs.getString("payload-token-value", null) ?: return null

            val refreshResponse = runBlocking {
                try { apiServiceLazy().refreshToken() } catch (e: Exception) { null }
            }

            if (refreshResponse != null && refreshResponse.isSuccessful) {
                val newToken = refreshResponse.body()?.token
                if (newToken != null) {
                    val host = refreshResponse.raw().request.url.host
                    cookieJar.saveToken(newToken, host)
                    return response.request.newBuilder()
                        .header("Authorization", "Bearer $newToken")
                        .header("Cookie", "payload-token=$newToken")
                        .build()
                }
            }

            cookieJar.clear()
            LocalBroadcastManager.getInstance(context).sendBroadcast(
                Intent("com.rekreacija.player.ACTION_SESSION_EXPIRED")
            )
            return null
        }
    }
}
```

The `priorResponse != null` check is the guard against infinite retry loops: if the refresh attempt itself returns a 401, the authenticator returns `null` rather than trying again. When that happens — meaning the refresh token itself has expired — the cookie jar is cleared and a broadcast is sent so the MainActivity can display the login screen.

---

## 4. Handling Polymorphic JSON in Kotlin

One of the less obvious rough edges when consuming Payload from a strongly-typed language is that relationship fields are polymorphic by design. At `depth=0`, a relation is returned as a plain ID string. At `depth=1` or higher, the same field becomes a full JSON object. In TypeScript this is manageable with union types and type guards. In Kotlin, Gson will crash on the mismatch unless you explicitly handle it.

There are three approaches, each with different tradeoffs.

**Option A: Runtime type checking with `Any?`** is the lowest-friction starting point. The field is deserialized as `Any?` and helper extension functions inspect it at runtime:

```kotlin
// android/app/src/main/java/com/rekreacija/player/data/model/Attendance.kt
data class AttendanceUser(
    val relationTo: String,
    val value: Any? = null,
    val id: Any? = null
)

fun AttendanceUser.resolvedId(): String? {
    id?.let { return it.toString() }
    return when (val nested = value) {
        is String -> nested
        is Number -> nested.toLong().toString()
        is Map<*, *> -> nested["id"]?.toString()
        else -> null
    }
}

fun AttendanceUser.resolvedName(): String {
    return when (val nested = value) {
        is Map<*, *> -> (nested["name"] as? String) ?: "Uporabnik"
        else -> "Uporabnik"
    }
}
```

This is the recommended approach for most cases because it handles schema variation without requiring a separate model class for each depth.

**Option B: Custom Gson deserializers** give you a more explicit conversion path when you need the deserialized type to always produce a specific class regardless of depth:

```kotlin
// android/pisarna/src/main/java/com/rekreacija/pisarna/data/model/PolymorphicRefAdapters.kt
object AttendanceUserAdapter : JsonDeserializer<AttendanceUser> {
    override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): AttendanceUser {
        val obj = json.asJsonObject
        val relationTo = obj.get("relationTo")?.takeUnless { it.isJsonNull }?.asString
        val valueEl = obj.get("value") ?: return AttendanceUser(relationTo = relationTo)
        
        return when {
            valueEl.isJsonPrimitive && valueEl.asJsonPrimitive.isNumber ->
                AttendanceUser(relationTo = relationTo, id = valueEl.asInt)
            valueEl.isJsonObject ->
                AttendanceUser(
                    relationTo = relationTo,
                    player = context.deserialize(valueEl, AttendancePlayer::class.java)
                )
            else -> AttendanceUser(relationTo = relationTo)
        }
    }
}
```

**Option C: Fallback getters on the model class** handle the case where Payload's response structure itself varies — specifically, whether relation arrays appear as top-level fields or nested under a `relationships` object. A computed property on the data class checks both paths:

```kotlin
// android/app/src/main/java/com/rekreacija/player/data/model/Event.kt
data class Event(
    val id: String,
    @SerializedName("attendances")
    val attendancesDirect: List<Attendance>? = null,
    val relationships: EventRelationships? = null
) {
    val attendances: List<Attendance>
        get() = when {
            !attendancesDirect.isNullOrEmpty() -> attendancesDirect
            !relationships?.attendances.isNullOrEmpty() -> relationships.attendances ?: emptyList()
            else -> emptyList()
        }
}
```

The UI layer always reads `event.attendances` without knowing which path the data came from. This insulates the UI from backend schema changes.

---

## 5. Querying Polymorphic Relations via REST

The polymorphic challenge extends beyond JSON parsing into how you construct query strings. When Payload stores a relation field as polymorphic — meaning it can point to records in more than one collection — it stores it internally as `{ value: <id>, relationTo: <collectionSlug> }`. Standard flat query syntax does not reach into this nested structure.

If you try to filter by a polymorphic field using the standard equality syntax, the query silently returns no results or throws an error:

```
GET /api/leagues?where[organizers][equals]=12
```

The correct syntax drills into the nested fields directly:

```
GET /api/leagues?where[organizers.value][equals]=12&where[organizers.relationTo][equals]=users
```

In Kotlin, build the query map explicitly rather than relying on Retrofit's `@Query` annotation, which does not handle this syntax cleanly:

```kotlin
val queries = mapOf(
    "where[organizers.value][equals]" to userId.toString(),
    "where[organizers.relationTo][equals]" to "users",
    "depth" to "1"
)
val response = api.getLeagues(queries)
```

This took me an embarrassing amount of time to track down. The symptom was a query returning zero results even when the records clearly existed in the database. If you're seeing empty responses on Payload collection reads and you're using polymorphic relations, check your query syntax here first.

---

## 6. Pitfalls and Debugging Tips

Most of the rough edges in this integration follow a pattern: something works fine during local development, then breaks in a specific context. Here are the ones that cost me the most time.

**The roster sync bug** manifests as a user successfully joining an event (the service returns 200, a subsequent join returns 409 Conflict as expected), but the event detail screen shows zero attendees. The root cause is that Payload's Local API does not automatically update the parent record's relation array when you create a related document. Creating an `attendance` record does not add it to `event.relationships.attendances`. The fix is to explicitly fetch the current array and append to it in the same service call — which is why the roster sync update is baked directly into `joinEvent` as shown in Section 2.

**Polymorphic access control crashes** happen once you start linking attendance records back to events. If your collection access rules use flat field syntax — `{ user: { equals: req.user.id } }` — they will fail on polymorphic user fields because Payload stores those as `{ user: { relationTo: 'players', value: 12 } }`. The 500 error appears only after the first attendance record is created, which makes it feel like a regression. Update your access control to match the nested structure:

```typescript
export const isAdminOrPolymorphicOwner = (): Access => {
  return ({ req: { user } }) => {
    if (!user) return false
    if (user.roles?.includes('ADMIN')) return true
    return {
      'user.value': { equals: user.id },
      'user.relationTo': { equals: 'players' }
    }
  }
}
```

**Debugging auth on a physical device** is awkward because you can't inspect network requests the same way you can in a browser. The fastest way I found to verify that the authentication cookie is being sent correctly is to extract it directly from the app's sandbox and replay it with curl:

```bash
# Read the cookie from SharedPreferences on the device
adb shell run-as com.rekreacija.player cat shared_prefs/payload_cookies.xml

# Replay the request with the extracted token
curl -H "Authorization: Bearer <TOKEN_VALUE>" -H "Cookie: payload-token=<TOKEN_VALUE>" \
  "http://localhost:3000/api/mobile/events/101?depth=2"
```

This tells you immediately whether the issue is the token value, the endpoint access policy, or the Android client itself.

**Local network connectivity** when testing on a physical Android TV or phone requires the device to be on the same subnet as your development machine, and for your backend's CORS configuration to allow the device's requests. Update the `BASE_URL` in `NetworkModule.kt` to your machine's LAN IP and add it to your backend's allowed origins:

```bash
# In your backend .env
MOBILE_CORS_ORIGINS=capacitor://localhost,http://192.168.1.XX:3000
```

---

## Wrapping Up

The core idea here is deceptively simple: keep business logic in a service layer that knows nothing about HTTP, React, or Android, and let adapters on both ends translate between that logic and their respective transport mechanisms. Payload CMS makes this practical because its Local API, custom endpoints, and native REST layer are all part of the same process — you're not maintaining two separate services, you're just adding a new surface to an existing one.

By the end of this implementation you have a single TypeScript function handling event registration for both web and Android, a Kotlin client that manages sessions transparently, and a clear pattern for handling the polymorphic data shapes that Payload introduces. The pitfalls documented in Section 6 are not edge cases — they will come up in any project that uses Payload with polymorphic relations and a mobile client.

Let me know in the comments if you run into anything not covered here, and subscribe for more practical guides on building full-stack apps with Payload CMS.

Thanks, Matija

## LLM Response Snippet
```json
{
  "goal": "Payload CMS Android integration: step-by-step guide to share Next.js services with Kotlin clients using Retrofit, OkHttp, persistent cookies and…",
  "responses": [
    {
      "question": "What does the article \"Payload CMS Android Integration: Next.js to Kotlin Guide\" cover?",
      "answer": "Payload CMS Android integration: step-by-step guide to share Next.js services with Kotlin clients using Retrofit, OkHttp, persistent cookies and…"
    }
  ]
}
```