BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Payload CMS Android Integration: Next.js to Kotlin Guide

Payload CMS Android Integration: Next.js to Kotlin Guide

Blueprint to share services across Next.js (Payload CMS) and Kotlin Android using Retrofit, OkHttp, persistent…

29th May 2026·Updated on:3rd June 2026··
Payload
Payload CMS Android Integration: Next.js to Kotlin Guide

Need Help Making the Switch?

Moving to Next.js and Payload CMS? I offer advisory support on an hourly basis.

Book Hourly Advisory

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • 1. The 4-Layer Backend Architecture
  • 2. Sharing Code Between Web and Android
  • Step 1: Write the Shared Service
  • Step 2: Adapt for Web (Server Action)
  • Step 3: Adapt for Mobile (Custom Endpoint)
  • 3. Android Client Implementation (Kotlin)
  • Step 1: Persistent Cookie Storage
  • Step 2: Assembling the OkHttp Client
  • Step 3: Retrofit Service Interface
  • Step 4: Parsing Error Responses from the Backend
  • Step 5: Automatic Token Refresh
  • 4. Handling Polymorphic JSON in Kotlin
  • 5. Querying Polymorphic Relations via REST
  • 6. Pitfalls and Debugging Tips
  • Wrapping Up
On this page:
  • 1. The 4-Layer Backend Architecture
  • 2. Sharing Code Between Web and Android
  • 3. Android Client Implementation (Kotlin)
  • 4. Handling Polymorphic JSON in Kotlin
  • 5. Querying Polymorphic Relations via REST
Build with Matija logo

Build with Matija

Modern websites, content systems, and AI workflows built for long-term growth.

Services

  • Headless CMS Websites
  • Next.js & Headless CMS Advisory
  • AI Systems & Automation
  • Website & Content Audit

Resources

  • Case Studies
  • How I Work
  • Blog
  • CMS Hub
  • E-commerce Hub
  • Dashboard

Headless CMS

  • Payload CMS Developer
  • CMS Migration
  • Multi-Tenant CMS
  • Payload vs Sanity
  • Payload vs WordPress
  • Payload vs Contentful

Get in Touch

Ready to modernize your stack? Let's talk about what you're building.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

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:

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

The correct syntax drills into the nested fields directly:

code
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