How I Connected Next.js + Payload CMS to an Android App (Kotlin)
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.
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.
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.
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.
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:
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:
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:
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.
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.
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:
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:
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.ktdataclassEvent(
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()
-> 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:
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.