Most mobile app architecture diagrams are too clean.
They show the phone, the API, the database, and maybe a web dashboard. A few arrows connect everything together. The system looks calm.
Real mobile products are not calm.
A user opens the app with weak signal. The GPS point arrives before the network does. The app is closed halfway through a request. The same action is retried. The web platform needs to show the latest data without turning every screen into a loading state. And somewhere between all of that, the backend still has to decide what is valid, what belongs to the user, and what should happen if the same write arrives twice.
That is the part I care about most.
I have already written about the product side of one-tap offline place saving for Apple Watch and iPhone. This article is the engineering side: how I think about a sync architecture for a real Expo app with a NestJS API, PostgreSQL as the source of truth, Swift where native iOS behavior matters, and a Next.js web platform on top.
The product requirement that shaped the architecture
The product sounds simple:
Save a private place now, organize it later.
But that one sentence creates a lot of technical pressure.
A place-saving app cannot behave like a normal web form. When someone saves a GPS point, the important part is not a beautiful confirmation screen. The important part is trust.
The user needs to feel that the place was captured even if:
- the network is unstable
- the app is backgrounded
- the GPS accuracy is still improving
- the Apple Watch sends a minimal payload first
- the user edits the place later from the phone
- the web platform renders the place from a different device
That means the architecture has to separate two jobs:
- Capture — store the user's intent as quickly and safely as possible.
- Reconcile — turn that intent into consistent server-side data.
Once I started thinking in those two layers, the stack made more sense.
Expo handles the mobile interface. Swift helps with native iOS and watch-related edges where JavaScript is not the right layer. NestJS gives the API a clear boundary. PostgreSQL becomes the durable source of truth. Next.js gives the user a larger web surface for browsing, editing, and managing places.
The architecture is not about making every layer clever.
It is about giving every layer a clear job.
The stack
The core stack looks like this:
- Expo / React Native for the iPhone app
- Swift for native iOS pieces where the JS layer should not own everything
- NestJS for the API and application boundary
- PostgreSQL for durable relational data
- Next.js for the web platform
- Object storage for media, if photos or generated assets are involved
That is a boring stack in the best possible way.
The mobile app should be great at interaction. The API should be great at validation and ownership. The database should be great at consistency. The web platform should be great at rendering and management.
A sync architecture gets painful when these responsibilities blur.
If the mobile app tries to become the source of truth, every device conflict becomes harder. If the backend only accepts perfect final objects, the capture flow becomes fragile. If the web platform owns too much mutation logic, the system slowly gets two APIs: the official backend and the accidental one hidden inside frontend code.
I try to avoid that.
The shape of a synced place
For a GPS place, the first server-side model does not need to be complicated.
The important thing is to preserve the event that happened:
type CreatePlaceRequest = {
clientId: string;
name?: string;
note?: string;
latitude: number;
longitude: number;
accuracy?: number;
capturedAt: string;
source: 'iphone' | 'apple_watch' | 'web';
};The small but important field here is clientId.
The backend has its own database ID, but the client also needs a stable ID before the server responds. Without that, retries become awkward. Optimistic UI becomes awkward. Queueing becomes awkward. And if the request succeeds but the response never reaches the client, the app has no reliable way to know whether the place already exists.
So I treat clientId as part of the sync contract.
The client generates it once. The server stores it once. If the same write arrives again, the API can return the existing place instead of creating a duplicate.
That is the beginning of idempotency.
Why idempotency matters more than people think
Mobile writes are messy.
A request can fail in at least three different ways:
- it never reached the server
- it reached the server but failed during validation or persistence
- it succeeded, but the client never received the response
From the user's point of view, all three can look the same.
That is why retrying the same action must be safe.
For place creation, the rule is simple:
The same user and the same client-generated ID should create one place, not many.
In PostgreSQL, that usually means a unique constraint:
create unique index places_owner_client_id_unique
on places (owner_id, client_id);Then the NestJS service can treat duplicate delivery as a normal condition, not an exceptional one.
async createPlace(userId: string, input: CreatePlaceDto) {
const existing = await this.placeRepository.findByClientId(
userId,
input.clientId,
);
if (existing) {
return existing;
}
return this.placeRepository.create({
ownerId: userId,
clientId: input.clientId,
latitude: input.latitude,
longitude: input.longitude,
accuracy: input.accuracy,
capturedAt: new Date(input.capturedAt),
source: input.source,
name: input.name ?? null,
note: input.note ?? null,
});
}This is not fancy architecture. It is the kind of boring rule that prevents annoying product bugs.
The app can retry confidently. The backend can defend itself. The user does not get five identical places because they saved something in the forest with bad signal.
The outbox pattern on mobile
For a real mobile app, I do not want every screen to think directly in HTTP requests.
The UI should say:
A place was created.
Then a sync layer should decide:
- can this be sent now?
- should it be queued?
- has this exact write already been sent?
- should it be retried?
- should the user see a pending state?
That is the mental model behind an outbox.
The local app keeps a small list of unsynced writes:
type PendingSyncAction = {
id: string;
type: 'create_place' | 'update_place' | 'delete_place';
payload: unknown;
createdAt: string;
attemptCount: number;
lastAttemptAt?: string;
};The UI does not need to wait for the network before it can show the new place. But it should still be honest. A place can be visible locally while still having a small pending state until the backend confirms it.
That distinction matters.
Optimistic UI is good when it makes the product feel fast. It becomes dangerous when it lies about durability.
I wrote about this from a frontend perspective in what actually makes a frontend feel fast in 2026. For sync-heavy mobile apps, the same principle applies: speed is not only about rendering quickly. It is about giving the user clear feedback at the right level of certainty.
"Saved locally" and "synced to the server" are not the same state.
The UI should not pretend they are.
NestJS as the sync boundary
I like NestJS for this kind of backend because it encourages a clean separation between transport, validation, services, and persistence.
For sync endpoints, that separation is useful.
A controller should not decide ownership rules. It should not know how to resolve duplicates. It should not know every detail of PostgreSQL. Its job is to receive the request, attach the authenticated user, and pass a validated command into the application layer.
@Controller('places')
export class PlacesController {
constructor(private readonly placesService: PlacesService) {}
@Post()
createPlace(
@CurrentUser() user: AuthenticatedUser,
@Body() body: CreatePlaceDto,
) {
return this.placesService.createPlace(user.id, body);
}
}The service owns the product rule:
@Injectable()
export class PlacesService {
async createPlace(userId: string, input: CreatePlaceDto) {
this.validateCoordinates(input.latitude, input.longitude);
const existing = await this.placesRepository.findByClientId(
userId,
input.clientId,
);
if (existing) {
return existing;
}
return this.placesRepository.createForOwner(userId, input);
}
private validateCoordinates(latitude: number, longitude: number) {
if (latitude < -90 || latitude > 90) {
throw new BadRequestException('Invalid latitude');
}
if (longitude < -180 || longitude > 180) {
throw new BadRequestException('Invalid longitude');
}
}
}The repository owns persistence:
@Injectable()
export class PlacesRepository {
async findByClientId(ownerId: string, clientId: string) {
// ORM or query builder implementation
}
async createForOwner(ownerId: string, input: CreatePlaceDto) {
// Insert into PostgreSQL
}
}This looks basic, but basic is good here.
Sync code tends to grow. First it is only place creation. Then it is updates. Then it is media. Then sharing. Then groups. Then subscription limits. Then conflict handling. If everything starts inside a controller, the API becomes difficult to reason about very quickly.
A clean NestJS boundary gives the system somewhere to put rules.
PostgreSQL as the source of truth
For this kind of product, PostgreSQL should not be treated like a passive JSON dump.
It should protect the core invariants:
- a place belongs to one owner
- a client-generated ID is unique per owner
- coordinates are valid
- deleted records are handled intentionally
- shared access is represented explicitly
- timestamps are stored consistently
- subscription limits can be checked against durable state
A simplified table might look like this:
create table places (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references users(id),
client_id text not null,
name text,
note text,
latitude double precision not null,
longitude double precision not null,
accuracy double precision,
source text not null,
captured_at timestamptz not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz,
constraint places_latitude_check
check (latitude >= -90 and latitude <= 90),
constraint places_longitude_check
check (longitude >= -180 and longitude <= 180)
);
create unique index places_owner_client_id_unique
on places (owner_id, client_id);
create index places_owner_updated_at_idx
on places (owner_id, updated_at desc);
create index places_owner_created_at_idx
on places (owner_id, created_at desc);I like this shape because it keeps the data boring.
Coordinates are coordinates. Ownership is ownership. Sync identity is explicit. Soft deletion is visible. The backend can build more complex product behavior on top without losing the simple truth underneath.
If geospatial querying becomes important later, the model can evolve. But the first job is not to impress the database. The first job is to keep user data safe, queryable, and consistent.
Why the web platform should not bypass the sync model
The Next.js web platform is not just a marketing site. For a mobile-first product, the web app often becomes the place where users manage data more comfortably.
That creates a temptation:
The web app is already close to the backend, so maybe it can use different mutation rules.
I try not to do that.
If the iPhone app, Apple Watch flow, and web platform all create or update places, they should go through the same domain rules. The web UI can be richer, but it should not invent a second version of the product logic.
This is where my approach to Next.js overlaps with the rules I use for Server vs Client Components in Next.js.
The server can assemble the page, fetch the user's places, protect access, and render a useful first view. Small client components can own the actual interaction: editing a name, moving a marker, opening a media modal, changing a category, or triggering a save.
The important part is that the mutation still goes through the same API contract.
The web platform should feel more comfortable than mobile, not less consistent than mobile.
Handling updates without creating a conflict machine
Create is the easiest sync action.
Update is harder because two devices can edit the same record. For many apps, the right first version is not a complex CRDT or real-time collaboration system. It is a smaller set of rules that match the product.
For a private place-saving app, most updates are not collaborative. A user might change the name, note, category, photo, or exact marker position. Those edits are usually personal and sequential.
So I would start with simple server-owned conflict rules:
- the server stores
updatedAt - the client sends the last known server version
- the backend rejects or marks suspicious stale updates
- the UI asks the user to refresh or reapply the change when needed
A minimal request might look like this:
type UpdatePlaceRequest = {
name?: string;
note?: string;
latitude?: number;
longitude?: number;
expectedUpdatedAt: string;
};Then the service can check whether the client edited an old version:
if (place.updatedAt.toISOString() !== input.expectedUpdatedAt) {
throw new ConflictException('Place was changed on another device');
}This is intentionally simple.
Most products do not need perfect automatic merging on day one. They need a clear conflict story that does not silently overwrite important data.
Start with correctness. Add smarter merging only where the product actually needs it.
Media should sync separately from place creation
Photos change the architecture.
A GPS point is small. A photo is not. A place can be captured immediately, but a photo upload might take longer, fail more often, or need a separate storage pipeline.
That is why I prefer treating media as a separate sync concern.
The flow can look like this:
- Create the place with coordinates and minimal metadata.
- Ask the API for an upload target.
- Upload the file to object storage.
- Confirm the media object with the backend.
- Attach it to the place.
That keeps the core capture flow reliable.
The user should not lose a saved place just because a photo upload failed. Media can retry later. The place itself should already exist.
This is the same product idea again:
Capture first. Enrich later.
I wrote about that product split in the post about one-tap offline place saving, but the engineering version is just as important. The system should not make optional context block the core data.
Privacy changes the backend shape
Pean is not a public map product. The default assumption is private places.
That changes the backend model.
A place is not just a row with coordinates. It is a private object with access rules. The API should always answer:
- who owns this place?
- who can read it?
- who can edit it?
- was this place shared directly or through a group?
- should this appear in another user's map?
- should media follow the same visibility rules?
That is why I would rather keep sharing explicit than magical.
A simplified sharing model might have separate tables for direct sharing and group-based access instead of hiding everything inside a JSON column. The exact schema can change, but the rule should stay stable:
Location privacy is a core product rule, not a UI preference.
This is also why a product like this is different from a generic maps app. I wrote more about that in why Google Maps is not enough for saving personal places. The backend has to respect the same idea. Private by default should be visible in the data model, the API, and the UI.
A sync endpoint is not just CRUD
A common mistake is treating sync as normal CRUD with worse network conditions.
It is more than that.
A sync endpoint needs to think about:
- idempotency
- retries
- partial failure
- local IDs
- stale data
- deleted records
- server timestamps
- ownership
- rate limits
- subscription limits
- schema changes over time
For example, a regular CRUD endpoint might say:
POST /places
PATCH /places/:id
DELETE /places/:idThat is fine as a transport shape, but the internal service still needs sync semantics.
For a mobile app, "create place" is not just "insert row." It is:
- validate the user's plan and limits
- check ownership
- accept a client-generated identity
- deduplicate retries
- preserve the capture timestamp
- return a server-confirmed version
- make the result visible to other clients
That is why I think of sync as an application layer, not a route naming style.
CRUD describes the HTTP surface.
Sync describes the behavior.
What I would monitor from day one
Sync bugs can be hard to see from the frontend because the UI often recovers silently.
That is useful for the user, but dangerous for the developer.
I would want to track:
- how many pending actions exist on the client
- how often sync retries happen
- how many writes are deduplicated by
clientId - how many requests fail validation
- how many conflicts happen on updates
- how often media upload succeeds after the place is already created
- how long it takes from local capture to server confirmation
These metrics tell you where the system feels fragile.
A high retry count might mean the API is fine but mobile connectivity is bad. A high deduplication count might mean the retry layer is working. A high conflict count might mean the web and mobile editing model needs better UX.
Without those signals, sync architecture becomes guesswork.
The architecture in one flow
If I reduce the system to one happy path, it looks like this:
- The user saves a place on iPhone or Apple Watch.
- The mobile app creates a local place with a
clientId. - The place appears immediately with a pending sync state.
- The sync layer sends the write to the NestJS API.
- The API validates ownership, coordinates, limits, and idempotency.
- PostgreSQL stores the durable server version.
- The mobile app receives the confirmed place.
- The Next.js web platform renders the same place from the server.
- Later edits use the same domain rules instead of a separate web-only path.
The flow is simple because each layer stays honest.
The phone captures. The API decides. PostgreSQL remembers. The web platform renders and manages.
What I would avoid next time
The biggest sync mistake is trying to make the first version too magical.
I would avoid:
- building a complex conflict resolution system before real conflicts exist
- blocking place creation on media upload
- making the web platform bypass the API rules
- treating local pending state as the same thing as server confirmation
- allowing mobile retries without idempotency
- hiding ownership and privacy rules inside UI-only logic
- building the backend as generic CRUD and hoping sync works later
The architecture should be strong, but not over-engineered.
A good first version has boring guarantees:
- the user does not lose the place
- duplicate requests do not create duplicate places
- private data stays private
- the server remains the source of truth
- the UI explains pending and synced states clearly
- the web platform uses the same rules as mobile
That is already a lot.
Final thought
A reliable sync architecture is not about making every device perfectly real-time.
It is about preserving user intent.
When someone saves a place, the system should understand what happened and carry that action safely from the device to the backend to the web platform. The user should not have to understand the network, retries, background execution, database constraints, or API boundaries.
They should tap save and trust the product.
For me, that is the real architecture goal.
Not just moving data from Expo to NestJS to PostgreSQL.
Making the product feel dependable at the exact moment it matters.