BuildWithMatija
  1. Home
  2. Blog
  3. Payload
  4. Proven Payload CMS Backend Architecture for Mobile & Web

Proven Payload CMS Backend Architecture for Mobile & Web

Four-layer pattern (query, service, web & HTTP adapters) to centralize business logic, add idempotency, versioning…

21st June 2026·Updated on:24th June 2026··
Payload
Proven Payload CMS Backend Architecture for Mobile & Web

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

  • The core problem
  • The four-layer architecture
  • 1. Query layer (`src/payload/db/*`)
  • 2. Service layer (`src/payload/services/*`)
  • 3. Web adapters (pages, layouts, Server Actions)
  • 4. HTTP adapters (native REST + custom endpoints)
  • Deciding REST vs. a custom endpoint
  • Version the mobile API before you need to
  • A response contract mobile clients can rely on
  • Centralize the error-to-status mapping
  • Validate request bodies before they reach a service
  • Worked example: discovery reads plus a workflow write
  • Make workflow writes idempotent
  • Protect capacity checks from concurrent joins
  • Authentication across two client types
  • CORS and CSRF when the client isn't a plain native HTTP stack
  • Handle profile update conflicts
  • FAQ
  • Conclusion
On this page:
  • The core problem
  • The four-layer architecture
  • Deciding REST vs. a custom endpoint
  • Version the mobile API before you need to
  • A response contract mobile clients can rely on
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
  • Topics
  • CMS Hub
  • E-commerce Hub
  • B2B Website Strategy
  • 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

If you're running a Next.js app on Payload CMS and adding a native Android (or iOS) client, the question that decides your architecture is simple: where does shared logic live, and how does the mobile app reach it without importing your TypeScript? The answer is a four-layer split — a read layer, a service layer, web adapters, and HTTP adapters — with one hard rule underneath all of it: external apps talk to HTTP, never to Server Actions, never to page code.

I built this layering on a recreational sports league platform (Next.js App Router, Payload CMS, a web "organizer office," and a native Android player app). The web app and the Android app needed to join events, update profiles, and read the same league data, and I wanted exactly one place where "can this player join this event" was decided. Here's the pattern that came out of it, the production hardening it needed once real phones were hitting it, and the endpoint contract I shipped.

The core problem

A native Android app can't import Server Actions or page components — it only speaks HTTP. The moment you add a mobile client, you're forced to answer where your business logic actually lives. Put it in a page component or a Server Action and you'll duplicate it the day you need the same operation from a custom endpoint. Put it in the right place once and both web and mobile call the same logic through different doors.

A browser client and a native client also fail differently, and the architecture has to account for that from the start. A browser usually runs whatever frontend you deployed an hour ago. A native app can sit on a phone for months on an old build, retry a request after a dropped connection, or send a payload shaped like last year's API. The HTTP adapter layer needs to absorb that, which is why the production-hardening section further down isn't optional polish — it's part of the contract.

The four-layer architecture

1. Query layer (src/payload/db/*)

This is where reads are centralized — payload.find and payload.findByID calls go here instead of scattered through pages and endpoints.

ts
// File: src/payload/db/events/index.ts
export async function findEventById(payload: Payload, id: string) {
  return payload.findByID({ collection: 'events', id })
}

Keep this layer server-only, read-focused, and free of revalidatePath, redirects, or any HTTP request/response handling. A function like findEventsByLeague should look the same whether it's called from a page or from a mobile endpoint.

2. Service layer (src/payload/services/*)

This holds shared write and workflow logic, and it's the layer that earns the architecture's name. A service accepts typed input plus the authenticated actor, uses the Payload Local API internally, enforces business rules, and throws structured errors rather than returning UI responses.

ts
// File: src/payload/services/join-event.ts
export async function joinEvent(payload: Payload, actor: Player, eventId: string) {
  const event = await findEventById(payload, eventId)
  if (!event) throw new ServiceError('NOT_FOUND')
  if (event.status === 'LOCKED' || event.attendanceCount >= event.capacity) {
    throw new ServiceError('CONFLICT')
  }
  // ensure league membership, then create attendance
}

joinEvent is a good example of why this layer exists at all. It isn't "create one attendance" — it checks auth, loads the event, rejects locked or full events, ensures league membership, prevents duplicate attendance, and only then creates the record. That sequence belongs in one function that both the web Server Action and the mobile endpoint call.

The version above reads fine in a demo and breaks under real concurrency, which the production section covers with a transaction-safe rewrite.

Services never call revalidatePath, never redirect, and never depend on browser-only concepts — those are web concerns, and the service layer stays usable from any HTTP adapter.

3. Web adapters (pages, layouts, Server Actions)

Server Actions stay thin. Their job is form parsing, validation-to-field-error mapping, calling the relevant service, then handling revalidatePath and redirects.

ts
// File: src/app/(frontend)/profil/actions.ts
export async function updateProfileAction(formData: FormData) {
  const actor = await getAuthenticatedPlayer()
  const result = await updateProfile(payload, actor, parseProfileForm(formData))
  revalidatePath('/profil')
  return result
}

Pages should read through src/payload/db/* directly rather than treating Server Actions as a general-purpose internal API.

4. HTTP adapters (native REST + custom endpoints)

This is the only layer an external app like an Android client can reach. It splits into two tools with different jobs, and for mobile specifically it carries more weight than "call the service" — it's where versioning, auth normalization, validation, error mapping, idempotency, and pagination all live, covered in detail further down.

Native Payload REST handles straightforward reads: GET /api/events, GET /api/leagues/:id, GET /api/locations. Payload already gives you filtering, pagination, depth, sort, and field selection here, so there's little reason to wrap it.

Custom endpoints handle workflow writes — anything that's more than simple CRUD, touches multiple collections, needs authorization beyond collection-level access, or needs a cleaner response shape than a raw Payload document. These endpoints call into db/* or services/* directly. They must never call a Server Action; that path doesn't exist for an HTTP adapter, by design.

Deciding REST vs. a custom endpoint

SituationUse
Simple list/detail read, access rules already match what the client should seeNative Payload REST
Simple CRUD write, access rules already match intended behavior, client can handle the raw document shapeNative Payload REST
Multi-step workflow (auth check, multiple validations, multiple collections touched)Custom endpoint calling a service
Client needs a stable DTO and app-specific success/error contractCustom endpoint
Authorization is more specific than what collection-level access config expressesCustom endpoint
List needs cursor pagination or conditional caching (ETag) for a growing datasetCustom endpoint — Payload's native REST only paginates by page/limit

In my project, this put GET /api/leagues/:id on native REST, and GET /api/mobile/v1/events, POST /api/mobile/v1/events/:id/join, and PATCH /api/mobile/v1/profile on custom endpoints — joining an event is a workflow, updating a profile from mobile needed its own validation and response shape, and listing events needed cursor pagination that native REST doesn't provide.

Version the mobile API before you need to

http
/api/mobile/v1/...

A web deploy reaches every user within minutes. A mobile build sits on a device for months, possibly never updating again. Add the version segment to every mobile path from day one:

http
GET /api/mobile/v1/events
POST /api/mobile/v1/events/:id/join
PATCH /api/mobile/v1/profile
POST /api/mobile/v1/auth/register

The point of versioning early is having somewhere to put a breaking change later — a different envelope shape, a renamed field, a changed workflow — while old app builds are still installed on real phones.

A response contract mobile clients can rely on

Every custom endpoint in this project returns the same envelope, which is what lets a single OkHttp/Retrofit layer in the Android app handle every workflow call the same way.

Success:

json
{
  "ok": true,
  "message": "Human-readable message.",
  "data": { "attendanceId": 99, "eventId": 42, "membershipCreated": true }
}

Error:

json
{
  "error": "CONFLICT",
  "message": "Localized message for the user.",
  "fieldErrors": { "name": ["Required field."] }
}

fieldErrors only appears on 422 validation failures. The status codes map onto familiar categories — 400 for a malformed body, 401 for missing auth, 403 for wrong role, 404 for a missing entity, 409 for a business-rule conflict like a full or locked event, and 422 for field validation.

Centralize the error-to-status mapping

Services throw domain errors. Endpoints translate those into HTTP responses. Leave that translation to each endpoint's own switch statement and the four status codes above start drifting apart across files — one endpoint returns 400 for a missing field, another returns 422 for the same case. Mobile clients notice that kind of drift fast, because a single error-handling path in the app now has to branch on inconsistent server behavior.

One mapper in the HTTP adapter layer fixes this:

ts
// File: src/payload/http/to-http-response.ts
export type ServiceErrorCode =
  | 'BAD_REQUEST'
  | 'UNAUTHORIZED'
  | 'FORBIDDEN'
  | 'NOT_FOUND'
  | 'CONFLICT'
  | 'VALIDATION_ERROR'
  | 'INTERNAL'

export class ServiceError extends Error {
  constructor(
    public code: ServiceErrorCode,
    message: string,
    public fieldErrors?: Record<string, string[]>,
  ) {
    super(message)
  }
}

export function toHttpResponse(error: unknown): Response {
  if (error instanceof ServiceError) {
    const statusByCode: Record<ServiceErrorCode, number> = {
      BAD_REQUEST: 400,
      UNAUTHORIZED: 401,
      FORBIDDEN: 403,
      NOT_FOUND: 404,
      CONFLICT: 409,
      VALIDATION_ERROR: 422,
      INTERNAL: 500,
    }
    return Response.json(
      {
        error: error.code,
        message: error.message,
        ...(error.fieldErrors ? { fieldErrors: error.fieldErrors } : {}),
      },
      { status: statusByCode[error.code] },
    )
  }
  return Response.json({ error: 'INTERNAL', message: 'Something went wrong.' }, { status: 500 })
}

Every custom endpoint then follows the same two-line shape:

ts
export async function handler(req: PayloadRequest) {
  try {
    const actor = requirePlayer(req)
    const input = parseJoinEventRequest(req)
    const result = await joinEvent(req.payload, actor, input)
    return Response.json({ ok: true, message: 'Pridružil/a se si dogodku.', data: result })
  } catch (error) {
    return toHttpResponse(error)
  }
}

The service stays focused on business rules. The mobile contract stays identical across every endpoint that uses it.

Validate request bodies before they reach a service

TypeScript types describe the code that builds a request — they say nothing about the JSON that actually lands from a phone running a build from three releases ago. Adding a schema check at the top of each custom endpoint catches a malformed payload as a clean 422 instead of an unhandled exception two layers down:

ts
import { z } from 'zod'

const UpdateProfileSchema = z.object({
  name: z.string().min(1),
  phone: z.string().optional(),
  preferredPosition: z.string().optional(),
  expectedVersion: z.string().optional(),
})

function parseUpdateProfileRequest(body: unknown) {
  const parsed = UpdateProfileSchema.safeParse(body)
  if (!parsed.success) {
    throw new ServiceError(
      'VALIDATION_ERROR',
      'Please check the highlighted fields.',
      parsed.error.flatten().fieldErrors,
    )
  }
  return parsed.data
}

This keeps malformed mobile requests out of the service layer entirely, where a failure would otherwise surface as a generic runtime error instead of a structured fieldErrors response the app can render next to the right input.

Worked example: discovery reads plus a workflow write

Event discovery for the player app uses a dedicated mobile read path rather than raw REST, because visibility rules matter: public events for everyone, private events only for authenticated league members.

http
GET /api/mobile/v1/events?status=SCHEDULED&leagueId=12&limit=25&cursor=eyJzdGFydHMiOiIyMDI2LTA2LTI0VDE4OjAwOjAwLjAwMFoiLCJpZCI6IjQyIn0=
Cookie: payload-token=...
json
{
  "ok": true,
  "data": {
    "items": [],
    "nextCursor": "eyJzdGFydHMiOiIyMDI2LTA2LTI1VDE4OjAwOjAwLjAwMFoiLCJpZCI6IjQ1In0="
  }
}

Cursor pagination, based on startsAt + id, keeps ordering stable while new events get added between requests — which page-number pagination doesn't guarantee on a list that changes underneath the user's scroll. For lists that refresh often, an ETag on top of this lets the client send If-None-Match and get back a 304 Not Modified instead of the full body when nothing changed; worth adding once the basic cursor flow is solid.

Joining that event is a workflow write, so it goes through the custom endpoint backed by the joinEvent service:

http
POST /api/mobile/v1/events/42/join
Authorization: JWT <token>
Idempotency-Key: 3f662e9b-8c42-4cb7-a6dc-6f0d1c2c9b7f
json
{
  "ok": true,
  "message": "Pridružil/a se si dogodku.",
  "data": { "attendanceId": 99, "eventId": 42, "membershipCreated": true }
}

The Android app never constructs a raw Payload document for this. It sends an ID and an idempotency key, gets back a small DTO, and the service decides everything in between.

Make workflow writes idempotent

Mobile networks drop connections in places a browser session never sees. The server can finish a join request while the client never receives the response, and the user's retry — or the client's own automatic retry — now looks identical to a second, separate join attempt. The duplicate-attendance check inside joinEvent catches some of this by accident, but an explicit Idempotency-Key header makes retries intentional instead of a side effect the service happens to absorb.

The client generates one UUID per logical action and sends it on every attempt of that action. The server stores the resulting response for a short window — a Redis key with a TTL of a few minutes works well here, or a dedicated table if you'd rather avoid adding Redis just for this — keyed on:

txt
playerId + endpoint + idempotencyKey

A repeat request with the same key returns the cached response instead of re-running the workflow. This matters most on:

txt
POST /api/mobile/v1/events/:id/join
POST /api/mobile/v1/events/:id/leave
POST /api/mobile/v1/auth/register
POST /api/mobile/v1/invites/:token/claim

Protect capacity checks from concurrent joins

The simple version of the capacity check reads fine and fails under load:

ts
if (event.status === 'LOCKED' || event.attendanceCount >= event.capacity) {
  throw new ServiceError('CONFLICT')
}

Two players hitting the last open slot at the same moment can both read the same attendanceCount, both pass the check, and both end up with an attendance record. The read-check-write sequence needs to run inside a transaction, with the event row locked or the capacity rule enforced at the database level:

ts
export async function joinEvent(payload: Payload, actor: Player, eventId: string) {
  const transactionID = await payload.db.beginTransaction()
  try {
    const req = { transactionID }
    const event = await findEventByIdForUpdate(payload, eventId, req) // locks the row

    if (!event) throw new ServiceError('NOT_FOUND', 'Event not found.')
    if (event.status === 'LOCKED' || event.attendanceCount >= event.capacity) {
      throw new ServiceError('CONFLICT', 'This event is already full.')
    }

    await ensureLeagueMembership(payload, actor, event.league, req)
    await ensureNoExistingAttendance(payload, actor, event.id, req)

    const attendance = await payload.create({
      collection: 'attendance',
      data: { event: event.id, player: actor.id },
      req,
    })

    await payload.db.commitTransaction(transactionID)
    return { attendanceId: attendance.id, eventId: event.id, membershipCreated: false }
  } catch (error) {
    await payload.db.rollbackTransaction(transactionID)
    throw error
  }
}

The exact locking mechanism depends on your database adapter. Postgres and SQLite, through Payload's Drizzle-backed adapters, support row locks and transaction-scoped advisory locks directly. MongoDB needs a replica set for multi-document transactions and behaves differently under the same pattern, so check your adapter's transaction support before assuming this code ports unchanged. The rule that survives across adapters: the service layer is the right place for the business rule, and the database still has to enforce it under concurrency.

Authentication across two client types

Native apps and the web app authenticate through different collections — players use players (POST /api/players/login, GET /api/players/me), organizers use users (POST /api/users/login, GET /api/users/me) through the web office — and they also benefit from different transport mechanisms at the protocol level.

A native HTTP stack like OkHttp or URLSession doesn't manage a cookie jar the way a browser does, and persisting one across app restarts adds work for no real benefit. Payload's login endpoint can return a JWT directly:

http
POST /api/players/login
Content-Type: application/json
json
{ "email": "player@example.com", "password": "password" }

The app stores that token in platform-secure storage (Keychain on iOS, EncryptedSharedPreferences on Android) and sends it on every request:

http
Authorization: JWT <token>
Client typeRecommended auth
Web appHTTP-only cookie
WebView / hybrid shellHTTP-only cookie, with correct CORS and CSRF settings
Pure native Android / iOSJWT in the Authorization header

The endpoint handler doesn't need to know which of these produced the request. It needs req.user to exist, belong to the right collection, and be allowed to perform the action — checked explicitly in every custom endpoint, since being reachable over HTTP isn't the same as being authorized.

Registration is claim-aware: POST /api/mobile/v1/auth/register accepts an optional inviteToken so a player invited by an organizer can adopt an admin-created record instead of creating a duplicate one, with data.mode: "claimed" | "created" in the response telling the client which happened.

CORS and CSRF when the client isn't a plain native HTTP stack

A pure OkHttp client with no browser origin sits outside CORS entirely. A WebView or hybrid shell sends an Origin header the moment it loads anything, and that origin needs to be on the allow list:

bash
# Comma-separated list, e.g. capacitor://localhost,https://app.example.com
MOBILE_CORS_ORIGINS=

These merge with NEXT_PUBLIC_URL and your local dev origin in payload.config.ts, and the same list governs CSRF. Cookie-based auth from a WebView needs HTTPS in production for the cookie to survive.

Handle profile update conflicts

A player editing their profile from two devices, or an organizer updating an admin-managed field while the player edits the same record from mobile, can overwrite each other's change silently without a concurrency check. PATCH /api/mobile/v1/profile carries an expectedVersion field for this:

json
{ "name": "Matija", "phone": "+386...", "expectedVersion": "2026-06-24T09:15:22.000Z" }

The server compares expectedVersion against the record's current updatedAt for an exact match. A mismatch returns:

json
{ "error": "CONFLICT", "message": "Your profile changed on another device. Please refresh and try again." }

This is lower priority than the capacity race above, and it's a cheap addition once the rest of the contract is in place.

FAQ

Can a custom endpoint call a Server Action? No. Server Actions are a web-only adapter, not part of the shared backend contract. A custom endpoint that needs the same logic should call the service the Server Action itself calls.

When is native Payload REST fine for a write, not just a read? When the operation is plain CRUD, the collection's access rules already match what the client should be able to do, and the client can work with Payload's raw document shape without a workflow step in between.

Why not let the mobile app hit the same Server Actions the web app uses? A native Android binary can't execute a Next.js Server Action — there's no JS runtime making that call. HTTP is the only contract available to it, which is exactly why the service layer needs to be reachable independently of the web adapter.

What happens to a private event read by ID without league membership? It returns 404 NOT_FOUND rather than 403, so the API doesn't reveal that a private resource exists at a given ID to a caller who isn't a member.

Why add an idempotency key instead of relying on the duplicate-attendance check? The duplicate check catches a retry that happens to look like a second join. An explicit key makes every retry identifiable as a retry, regardless of which workflow it's attached to, instead of depending on each service to have its own incidental safeguard.

Where do new mobile-only features go first? Add the read helper or service first, then expose it through a new /api/mobile/v1/* custom endpoint. Resist the shortcut of writing the logic directly inside the endpoint handler — that's exactly the duplication this layering exists to avoid.

Conclusion

A mobile client forces a decision you should be making anyway: business logic belongs in services and reads belong in a query layer, with both web adapters and HTTP adapters as thin, swappable doors into that same code. Once that split is in place, native REST handles your simple reads, custom endpoints handle your workflows, and a consistent JSON envelope means your Android client can treat every workflow call identically.

Getting the layering right earns you the architecture. Getting it through real phones on real networks takes the production layer on top: a version prefix, one error mapper, request validation, idempotency keys on workflow writes, transaction-safe capacity checks, and cursor pagination on growing lists. Adding a second client — an organizer app, a partner integration — then becomes a matter of writing a new adapter against a contract that already holds up, not re-deriving the business rules or relearning what broke in production the first time.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija