Notes on learning stage of developing my own React Native education app using Expo Framework - Part 4

Last updated on 13 Oct. 2025

Main tutorial used for learning: Simon Grimm: Build Your Own MASTERCLASS clone in React Native, https://www.youtube.com/watch?v=fO3D8lNs10c , 4 hrs. 42 mins, Mar. 2025.

=======================================

22:04, 11 October 2025

I am now reviewing the Waitlist part of the tutorial video.
In soon.tsx, SG imports Waitlist from '@clerk/clerk-react' Ref: https://youtu.be/fO3D8lNs10c?t=16009
SG gets "@clerk/clerk-react: Waitlist can only be used within the <ClerkProvider /> component...." error message when using web app's soon page. https://youtu.be/fO3D8lNs10c?t=16019

Even after he sets waitlistUrl prop in _layout.tsx for ClerkProvider component (Ref: https://youtu.be/fO3D8lNs10c?t=16047 ), he gets the same error for web (Ref: https://youtu.be/fO3D8lNs10c?t=16053 ).

But in _layout.tsx, at least in my app code which would have been copied from SG repo, I have:
import { ClerkProvider, ClerkLoaded, useAuth } from '@clerk/clerk-expo';

In my app currently, soon.web.tsx imports Waitlist from '@clerk/clerk-react'. I do not get the above error. Is it because it is a platform specific (web.tsx) page?

Also should SG have imported Waitlist from '@clerk/clerk-expo/web'? Ref: https://clerk.com/docs/expo/reference/components/authentication/waitlist

Don't know if I should also use import Waitlist from '@clerk/clerk-expo/web' . But as the code is working with import Waitlist from '@clerk/clerk-react', I don't want to make changes.

As I have mentioned, at least partially, in my earlier notes, SG adds 'use dom'; to top of soon.tsx and wraps the Waitlist in a ClerkProvider component also imported from @clerk/clerk-react (Ref: https://youtu.be/fO3D8lNs10c?t=16129 ) , after which the above error message goes away for web and the Waitlist screen is shown (and it also shows on mobile!).

But how does SG not get an error for two includes/imports of ClerkProvider (one from _layout.tsx and another from soon.tsx). Is it because they are imported from different packages ('@clerk/clerk-expo' and '@clerk/clerk-react')?

======================

Now starting next segment of video: Transfer Data to Strapi Cloud, https://www.youtube.com/watch?v=fO3D8lNs10c&t=16258s

https://strapi.io/pricing-cloud shows that free tier does not seem to have a trial period (which is what SG video shows, https://youtu.be/fO3D8lNs10c?t=16285 ). Hmm. Looks like that has changed for the better!

I created a login on Strapi cloud.
==================

As a first step before importing Ravi-lms-api from GitHub to Strapi cloud, I want to have all relevant data in the GH repo, at least made as similar to original SG lms-api repo as possible.
But I will exclude the 15 MB backup.tar.gz file. [Its assets/uploads folder contents size is same as public/uploads folder mentioned below, except for a 0 KB .gitkeep file in the latter, and so is the major part of the 15 MB file.]
The other big folder in the project on PC is public/uploads which is 14.2 MB.

The original .gitignore to which I have only added, already has:
public/uploads/*
!public/uploads/.gitkeep
----
That excludes all the big media files and 'unignores' or includes only the 0 KB .gitkeep file.
The database folder that I had included in .gitignore has only a 0 KB .gitkeep file in a subfolder migrations. Now I have removed the database folder line from .gitignore.
I dropped the banner.png exclusion (910 KB file).

So the only big exclude needed is backup.tar.gz. That is now the only added statement to .gitignore.
Commit: Made .gitignore very close to SG lms-api; Only additional statement now is exclusion of 15 MB backup.tar.gz file

Now I will try creating a new project in strapi cloud and importing code from https://github.com/ravisiyer/SG-EduApp-Ravi-LMS-API
STRAPI cloud Project name: sg-eduapp-ravi-lms-api
Connected above GH repo to it. 
Unchecked: Deploy the project on every commit pushed to this branch
Left the Region as default US (East) ... [Asia (Southeast) is an option but not Asia (South)]
I skipped adding env. variables at this stage.
Clicked Create project.
It finishes initialization and next step (configuration, IIRC) quite quickly. Last step of Deploying is taking longer.
It got "Deployed in 4 min and 4s".
App URL: https://majestic-benefit-7638ba90d8.strapiapp.com/admin
  Trying out above link in anonymous window brings up a login screen. So I am sharing above link on this post.

Clicking the 'Visit App' link took me to a cloud STRAPI credentials creation page. I created a user (saved info ...)
Now I see similar admin panel as in local instance with Content Manager showing the same Collection Types and Single Types. Only the data is empty in each of them. 
This is where I think SG video showing how to transfer data from local to cloud becomes important. I will follow that and see what happens.
I also have to set up the env vars. But let me transfer data first. Ref: https://youtu.be/fO3D8lNs10c?t=16398
npx strapi transfer --to url-of-hosted-instance
So for me, it becomes:
npx strapi transfer --to https://majestic-benefit-7638ba90d8.strapiapp.com/admin
It needs a transfer token.
Settings in App URL shows Transfer Token button on left.
I created token named: UploadFromLocal, duration: Unlimited, type: full access, Desc: blank
Saved and then copied token:
---snipped----------------------------
Ran:
npx strapi transfer --to https://majestic-benefit-7638ba90d8.strapiapp.com/admin
gave the token.
It took time (as per console output below 208118 ms which is around 3.5 minutes but it seemed a little longer to me - more like 5 to 10 minutes but I was not noting the time taken) to do the assets transfer (14.2 MB). I am on Jio SIM. Next time for such work, I should use Airtel
It got done eventually. Console output:
Ravi-lms-api> npx strapi transfer --to https://majestic-benefit-7638ba90d8.strapiapp.com/admin
? Please enter your transfer token for the remote Strapi destination [hidden]
? The transfer will delete existing data from the remote Strapi! Are you sure you want to proceed? Yes
Starting transfer...
✔ entities: 92 transfered (size: 48 KB) (elapsed: 3277 ms) (14.6 KB/s)
✔ assets: 16 transfered (size: 14.2 MB) (elapsed: 208118 ms) (70 KB/s)
✔ links: 211 transfered (size: 41 KB) (elapsed: 3122 ms) (13.1 KB/s)
✔ configuration: 31 transfered (size: 110.7 KB) (elapsed: 2180 ms) (50.8 KB/s)
┌─────────────────────────────────────────┬───────┬───────────────┐
│ Type                                    │ Count │ Size          │
├─────────────────────────────────────────┼───────┼───────────────┤
│ entities                                │    92 │        48 KB  │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::course.course                   │     4 │ (    10.1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::home.home                       │     2 │ (     2.1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::lesson.lesson                   │    30 │ (    12.1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::progress.progress               │     4 │ (       1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::user-course.user-course         │     6 │ (     1.8 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::i18n.locale                  │     1 │ (     253 B ) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::upload.file                  │     4 │ (     8.8 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::users-permissions.permission │    37 │ (    10.2 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::users-permissions.role       │     2 │ (     651 B ) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::users-permissions.user       │     2 │ (       1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ assets                                  │    16 │      14.2 MB  │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- .mp4                                 │     1 │ (     4.5 MB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- .png                                 │    15 │ (     9.7 MB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ links                                   │   211 │        41 KB  │
├─────────────────────────────────────────┼───────┼───────────────┤
│ configuration                           │    31 │     110.7 KB  │
├─────────────────────────────────────────┼───────┼───────────────┤
│ Total                                   │   350 │      14.4 MB  │
└─────────────────────────────────────────┴───────┴───────────────┘
Transfer process has been completed successfully!
Ravi-lms-api> 
===========================

Checked the data on cloud. It is all OK including user, user-course and progress collection which show what it is in local STRAPI.
=============

Email from STRAPI cloud:
Thank you for being a Strapi Cloud customer. Please find below more information about your Strapi Cloud Free v1 USD Monthly subscription.

Subscription summary
Strapi Cloud Free $0.00

Next renewal on
01-Nov-2025 21:39
---------------------
Does that mean that there is a trial period which expires on 1 Nov. 2025?
-------

11:58, 12 October 2025

Strapi cloud dashboard - https://cloud.strapi.io/projects

Regarding environment variables settings (Accessed from Project -> Settings -> Environment -> Variables). It has already set the default variables to some values ...
---Key data from screen ---
Default environment variables
Each Strapi project comes with some mandatory variables to allow successful builds and deployments.
VARIABLE NAME

APP_KEYS  *****
API_TOKEN_SALT  ***** 
ADMIN_JWT_SECRET  *****
JWT_SECRET  *****
TRANSFER_TOKEN_SALT  *****

Custom environment variables
Control the environment your site gets deployed to. Please redeploy your project after editing variables.

---end key data from screen ---

So the only env. variables i need to set (as per .env in local project) are:
HOST='0.0.0.0'
PORT=1337
----------

Setting Description Type Default value
...
HOST Address the Strapi server listens on String 0.0.0.0
PORT Port used by the Strapi server Number 1337
-------------

So I don't think I need to set the above env. variables in strapi cloud.

-------
IFIRC, I have a valid free tier render a/c. So following article may be interesting:
Deploy Strapi on Render, https://render.com/docs/deploy-strapi (not read yet)
-------

Testing GET API via browser:
For localhost deployment, following url in browser works (gives courses data):
http://localhost:1337/api/courses
-----
But below url does not return data (browser spinner shows for long and then browser shows ERR_CONNECTION_TIMED_OUT)
https://majestic-benefit-7638ba90d8.strapiapp.com:1337/api/courses

But below url works (gives courses data)!
https://majestic-benefit-7638ba90d8.strapiapp.com/api/courses

CG explained it as follows:

In local Strapi development, the default server runs on port 1337 (e.g., http://localhost:1337).
However, when a project is deployed on Strapi Cloud, that internal port is not publicly exposed.

Strapi Cloud hosts each app behind a reverse proxy that serves traffic only on the standard web ports — 80 (HTTP) and 443 (HTTPS) — and internally forwards it to port 1337.

As a result, any URL containing :1337 (for example,
https://majestic-benefit-7638ba90d8.strapiapp.com:1337/api/...) will time out,
while the version without the port
(https://majestic-benefit-7638ba90d8.strapiapp.com/api/...) works correctly.

In short:
Use :1337 locally, but omit it on Strapi Cloud — the platform handles port routing automatically.

==========================

https://majestic-benefit-7638ba90d8.strapiapp.com/api/courses works even in incongito browser!
Hmm. I need to check about the security settings. 
...

On https://majestic-benefit-7638ba90d8.strapiapp.com/admin
 Settings > Users & Permissions Plugin > Roles -> Public -> Course
 shows that find and findOne permissions are checked and create, delete and update are unchecked.

 Settings > Users & Permissions Plugin > Roles -> Public -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.

 Settings > Users & Permissions Plugin > Roles -> Public -> User-course
 shows that create,find and update permissions are checked and delete and findOne are unchecked.


 Settings > Users & Permissions Plugin > Roles -> App User -> Course
 shows that find and findOne permissions are checked and create, delete and update are unchecked.

 Settings > Users & Permissions Plugin > Roles -> App User -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.

 Settings > Users & Permissions Plugin > Roles -> App User -> User-course
 shows that find, findOne, create, delete and update are permissions are unchecked.
 This is strange as Public for this collection has create,find and update permissions checked.
 Also how does this play out when the app tries to add an entry to the user-course?


On http://localhost:1337/admin
 Settings > Users & Permissions Plugin > Roles -> Public -> Course
 shows that find and findOne permissions are checked and create, delete and update are unchecked.
 That is same as on strapi cloud (see above).
 
 Settings > Users & Permissions Plugin > Roles -> Public -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.
 That is same as on strapi cloud (see above).
 
 Settings > Users & Permissions Plugin > Roles -> Public -> User-course
 shows that create,find and update permissions are checked and delete and findOne are unchecked.
 That is same as on strapi cloud (see above).


 Settings > Users & Permissions Plugin > Roles -> App User -> Course
 shows that find and findOne permissions are checked and create, delete and update are unchecked.
 That is same as on strapi cloud (see above).

 Settings > Users & Permissions Plugin > Roles -> App User -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.
 That is same as on strapi cloud (see above).

 Settings > Users & Permissions Plugin > Roles -> App User -> User-course
 shows that find, findOne, create, delete and update are permissions are unchecked.
 That is same as on strapi cloud (see above).
-----------

So local strapi admin settings for Users & Permissions Plugin matches that of strapi cloud. That is as expected.
But the following settings are strange even for a tutorial project where we would want to give read access to public for easy API testing (say, via browser):
 Settings > Users & Permissions Plugin > Roles -> Public -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.
Why should Public user be allowed to create, delete and update?

 Settings > Users & Permissions Plugin > Roles -> Public -> User-course
 shows that create,find and update permissions are checked and delete and findOne are unchecked.
Why should Public user be allowed to create and update User-course?
 Settings > Users & Permissions Plugin > Roles -> App User -> User-course
 shows that find, findOne, create, delete and update are permissions are unchecked.
Why should App User be denied create, find and findOne?

=========
On https://majestic-benefit-7638ba90d8.strapiapp.com/admin

 Settings > Users & Permissions Plugin > Roles -> App User shows 2 users
 And Content Manager for User collection shows for both user entries a role of 'App User'
 I need to check the code that creates a user for this 'App User' setting.

 Settings > Users & Permissions Plugin > Roles -> App User does not list User collection under Permissions but lists all the other collections and has some more entries like Content types builder and Email.
 So looks like User collection in app is a special collection known to Strapi as App User collection.
 
 Settings > Administration Panel > Users lists "All the users who have access to the Strapi admin panel" which is only one now.
 So this panel's users is admin user and different from App User.
 
================
https://majestic-benefit-7638ba90d8.strapiapp.com/api/users from anonymous browser window shows the two registered user entries!
Hmm. How is API access to this collection controlled?

============================

Above security aspects are not critical to check now. 
I am now focusing on getting the web frontend on localhost to work with strapi cloud.

....
Me: In the React native tutorial project that uses above Strapi backend, the tutorial author mentioned having two .env files in the frontend - a .env and a .env.local with .env variable pointing to strapi cloud and .env.local pointing to localhost strapi.

Now does .env.local have any special meaning to Metro or webpack bundlers that come into play when i use npm run start to run the frontend?

[CG gave an incorrect answer that "In React Native, .env and .env.local are not automatically special". Actually, at least currently, Metro does prefer .env.local over .env. I don't know whether it also does an automatic merge or override of variables from these two files with .env.local being given priority.]

Now I needed multiple env files. CG suggested using script commands to ease that. That needed multiple iterations to get a working version which is given below.
In package.json:
  "scripts": {
    "start": "expo start",
    "start:local-be": "copy .env .env.back && copy .env.localback .env && expo start",
    "start:cloud-be": "copy .env .env.back && copy .env.cloud.be .env && expo start",
    "reset-project": "node ./scripts/reset-project.js",
    ...
  },
----
Note that the above works on Windows 11 but most probably will not work on Linux/macOS.

I also keep a separate .env.cloud.fs which will have values to be used when front-end and back-end are on cloud or not local. In this case, .env.cloud.fs will simply be a convenience file to upload env variable values into hosting service like EAS.

Added these multiple .env files as excluded using following in .gitignore
.env.*
-----

With the above setup:
  • To run local lms frontend with local strapi backed: npm run start:local-be
  • To run local lms frontend with cloud strapi backed: npm run start:cloud-be
  • To run local lms frontend with last used strapi backed: npm run start
=============

I need to drop assets from .gitignore before hosting frontend on EAS or similar service.
...

app\api\add-user-course+api.ts uses STRAPI_API_ADMIN_TOKEN to add user course via API. That's why the role permissions for user-course don't matter, I think.

https://docs.strapi.io/cms/data-management/transfer states "Admin users and API tokens are not transferred."

So looks like I will need to regenerate the STRAPI_API_ADMIN_TOKEN on cloud and use that regenerated value in env.cloud.be and env.cloud.fs.
Did that.

To test it (in PS):

curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/courses
returned 200 Status code and JSON data in Content.

curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
returned 200 Status code and JSON data in Content.

I think the above is happening as on https://majestic-benefit-7638ba90d8.strapiapp.com/admin
 Settings > Users & Permissions Plugin > Roles -> Public -> User-course
 shows that create,find and update permissions are checked and delete and findOne are unchecked.

To protect User-course from anonymous create, find and update, I need to uncheck them in above. ... Did that and saved in cloud.
Now ...
lms> curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
curl : {"data":null,"error":{"status":403,"name":"ForbiddenError","message":"Forbidden","details":{}}}
At line:1 char:1
+ curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-cours ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest], WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
lms> 
-----
So the data protection from public works.

curl -H 'Authorization: Bearer --snipped--' https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses

lms> curl -H 'Authorization: Bearer bd-snipped-' https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
Invoke-WebRequest : Cannot bind parameter 'Headers'. Cannot convert the "Authorization: Bearer bd-snipped-" value of type "System.String" to type 
"System.Collections.IDictionary".
At line:1 char:9
+ curl -H 'Authorization: Bearer bd--snipped-- ...
+         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidArgument: (:) [Invoke-WebRequest], ParameterBindingException
    + FullyQualifiedErrorId : CannotConvertArgumentNoMessage,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
 
lms> 

On asking CG for command prompt version in PS, it suggested:
cmd /c curl -H "Authorization: Bearer bd--snipped--" https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
...

lms> cmd /c curl -H "Authorization: Bearer bd--snipped--" https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
>>
{"data":[{"id":5,"documentId":"lj7bj7293s80l3w3iq9wtpww","clerkId":"user_2xBpZwuCMK7AUEKRFT4Q1hcm9uk","finished_percentage":10,"next_lesson_index":"2","createdAt":"2025-10-09T18:11:42.154Z","updatedAt":"2025-10-10T08:18:59.002Z","publishedAt":"2025-10-10T08:18:59.011Z"},{"id":2,"documentId":"mo06lybnrsehtdrd09a5k4r9","clerkId":"user_2xBpZwuCMK7AUEKRFT4Q1hcm9uk","finished_percentage":20,"next_lesson_index":"2","createdAt":"2025-10-01T15:05:19.834Z","updatedAt":"2025-10-08T18:50:10.752Z","publishedAt":"2025-10-08T18:50:10.759Z"},{"id":6,"documentId":"vjcisu67y1r7x2kmbvm2rqlh","clerkId":"user_2xM5iCisxx9uJQ3XjDJM83T0oLz","finished_percentage":30,"next_lesson_index":"4","createdAt":"2025-10-09T18:15:35.351Z","updatedAt":"2025-10-11T11:58:21.748Z","publishedAt":"2025-10-11T11:58:21.757Z"}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":3}}}
lms> 

cmd /c curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/courses
{"data":[{"id":4,"documentId":"caidhct21sy7lhabvqhauho9","title":"馃‍♀️ Beginner’s Yoga Journey – Flow Into Balance 馃尶","description n":[{"type":"paragraph","children":[{"text":"✨ Welcome to Your Yoga Practice! ✨","type":"text"}]},{"type":"paragraph","children":[{"text":"Step onto the mat and begin a journey of self-discovery, movement, and breath. This beginner-friendly yoga course is designed to help you build strength, improve flexibility, and cultivate mindfulness—one breath at a time.","type":"text"}]},{"type":"paragraph","children":[{"text":"","type":"text"}]},{"type":"paragraph","children":[{"text":"馃挍 What You’ll Learn:","type":"text"}]},
...
------------------
======================

Me: Now about API key security issue. The code that uses this env variable is in: app\api\add-user-course+api.ts
So it is an API in React native and Expo setup.
No other source file uses this env variable.
Is this API key protected from client or exposed to client

[CG initially gave an inaccurate response perhaps based on old data. It also suggested how to test on web client using DevTools Console whether the API key env variable is exposed...]

Me: [Sharing DevTools Console data after trying out above CG suggestion:] 
Warning: Don’t paste code into the DevTools Console that you don’t understand or haven’t reviewed yourself. This could allow attackers to steal your identity or take control of your computer. Please type ‘allow pasting’ below and press Enter to allow pasting.
allow pasting
console.log('Environment:', process.env.STRAPI_API_ADMIN_TOKEN);

VM328:1 Environment: undefined
undefined
----

CG: ...

So your test confirms:

  • On the web, STRAPI_API_ADMIN_TOKEN is not exposed.

  • On mobile bundles, if you import it in client code, it could be exposed.

...

[In response to my query, ] CG said, "Yes — if you want to directly check whether the admin token is bundled in the mobile app, you will need to temporarily add a small console.log in your source code."
...

Me: I think this is a new feature. https://docs.expo.dev/router/reference/api-routes/ says:

Security

Route handlers are executed in a sandboxed environment that is isolated from the client code. It means you can safely store sensitive data in the route handlers without exposing it to the client.

  • Client code that imports code with a secret is included in the client bundle. It applies to all files in the app directory even though they are not a route handler file (such as suffixed with +api.ts).
  • If the secret is in a <...>+api.ts file, it is not included in the client bundle. It applies to all files that are imported in the route handler.
  • The secret stripping takes place in expo/metro-config and requires it to be used in the metro.config.js.
---------

CG agreed and said: With Expo Router API routes, files like add-user-course+api.ts run in a server-only sandbox. Admin secrets used there are not exposed to the client bundle, as long as the file is not imported into client-side code. This is a safe way to store and use STRAPI_API_ADMIN_TOKEN in your React Native + Expo project.
...

Me: For the web app, it will be like Next.js server side stuff, I guess - so I get that.
But for mobile app, how does the server side stuff get invoked? Where is that code running? Does it latch on to the web app deployed somewhere and whose url is available in some config file?

...[Omitting CG response as it was not relevant.]

Me: My app does not have a EXPO_PUBLIC_BASE_URL anywhere.
But app.json has:
    "plugins": [
      [
        "expo-router",
        {
          "origin": "https://fake-devonly-lms.expo.app"
        }
      ],
      [
        "expo-splash-screen",
        {
          "image": "./assets/images/splash-icon.png",
          "imageWidth": 200,
          "resizeMode": "contain",
          "backgroundColor": "#ffffff"
        }
      ],
      [
        "expo-video",
        {
          "supportsBackgroundPlayback": true,
          "supportsPictureInPicture": true
        }
      ]
    ],
------
I think in production, the expo-router origin has to be set up to point to deployed web app. That must be the way the mobile app knows where to go to invoke the api hosted on server.

CG: Exactly — you’ve got it. That origin in your app.json tells the Expo Router where the server-side API routes live for your mobile app to call.
...

Me: In the tutorial, STRAPI_API_ADMIN_TOKEN is defined in .env file.
I confirmed through search across project code that other than .env file(s) (which are not exposed to client or on repo), the only file using it is the api source code file. 

So I guess that even though STRAPI_API_ADMIN_TOKEN is defined in .env file, it will not be accessible to mobile or web client.

But would it not be better to include it in some constants.js file in the api folder (if used in multiple apis) or directly in the api file (if used only in one api file)? This way even if some client code mistakenly refers to process.env.STRAPI_API_ADMIN_TOKEN , it won't work or be visible to it?

Or is it that since we don't have EXPO_PUBLIC_ prefix to STRAPI_API_ADMIN_TOKEN we can be sure that process.env.STRAPI_API_ADMIN_TOKEN will not be accessible on client code?

...

Me: U wrote: Because your token is non-prefixed (EXPO_PUBLIC_) and only used inside +api.ts route handlers, it is not accessible to mobile or web clients. Using a constants file in api/ can help prevent accidental exposure, but .env usage is already safe as long as the code separation is maintained.
-----
The key point is that it is not prefixed with EXPO_PUBLIC_ and so even if it is in .env file (or .env.local or .env.cloud.be which we copy as .env when needed), we are assured that even if by mistake some client side code refers to it, it will be undefined. Did I get that right?

CG: Perfectly right — ✅ you’ve captured the core guarantee of Expo’s environment variable system.
...
In Expo, only environment variables prefixed with EXPO_PUBLIC_ are bundled into the client app. Since STRAPI_API_ADMIN_TOKEN has no such prefix, it remains private and cannot be accessed by mobile or web client code — even if mistakenly referenced.
...
-----------------
Discussion on .env variants ...
.env.local does get into play before .env in Expo
So renamed it to .env.localback
--------

Stopped lms-api local back-end.
Ran: [npm run] start:cloud-be
lms web front-end did connect to cloud backend but some issues:
1) Images were not shown (as for Dev we prefix image url with EXPO_PUBLIC_STRAPI_API_URL which seems to be an issue for strapi cloud)...
2) User-courses was not accessible. Fixed that by giving Cloud Role public access to User-course find and findOne. [I wanted to stay as close to tutorial as feasible. So did not change App User role for User-course which has all operations unchecked (disallowed).]

Commit: local frontend works partially with cloud strapi backend; Images are not shown

Discussed code to detect local or cloud strapi backend and modify image/video url correctly. In providers\StrapiProvider.tsx:

  const baseUrl = process.env.EXPO_PUBLIC_STRAPI_API_URL as string;
  let backendMode: 'local' | 'cloud' | 'unknown';

  if (baseUrl.startsWith('https://')) {
    backendMode = 'cloud';
  } else if (baseUrl.startsWith('http://')) {
    backendMode = 'local';
  } else {
    backendMode = 'unknown';
  }

  console.log(`Strapi backendMode: ${backendMode}`);
  console.log(`Strapi API URL: ${baseUrl}`);
...
      result.data = result.data.map((item: any) => {
        const image = (__DEV__) && backendMode === 'local'
        ? `${baseUrl}${item.image.url}`  // In Development
        : `${item.image.url}`
        return {
          ...item,
          image,
        }
      });
...
---------
Above approach worked (showed images and video on app frontend).

But progress to next lesson gave forbidden error for User-course.
So added update permission for Public to User-course in cloud Strapi.
That fixed it.

Switching between local and cloud strapi using:
npm run start:cloud-be
and
npm run start:local-be
worked. The images and video were shown correctly in both cases.

Commit: Fixed image issue with code to detect local or cloud strapi backend and modify image/video url correctly

Now testing local and then cloud backend with app on phone.

One issue with cloud backend on mobile (and I think on web too but I need to confirm that), is that moving to next lesson automatically from playing current lesson to end, sometimes starts the video and then restarts it. The restart is visible as the video plays a little. When I switched back to local backend, I saw that the restart happens on local backend too, except that the restart is quick and so harder to spot.

Modified lesson/index.tsx as follows:
  if (lesson?.video) {
    // Does this cause the video to restart at times?
    console.log("Replacing video in player for lessonIndex", lessonIndex);
    player.replace(lesson.video);
  }
-----

After above change, I am not facing the restart issue using local strapi on mobile. But that seems to be a coincidence as above addition is just a console.log statement. Perhaps it needs some other condition to manifest. But now I have the console.log statements to check when the issue arises.
I tested for long on local strapi with mobile app, going through entire lesson set of Beginner's Yoga ... course twice. No restart and no repeat of console message for a lesson index.

Then I switched to cloud strapi. Very quickly I got the restart and the above console message for the same lesson index was repeated:
 (NOBRIDGE) LOG  In playToEnd event handler. Ignoring spurious playToEnd event during mount
 (NOBRIDGE) LOG  Replacing video in player for lessonIndex 8
 (NOBRIDGE) LOG  In useEffect for updating course progress. externalNavigationRef.current = true
 (NOBRIDGE) LOG  Entered updateUserCourseProgress with: {"courseId": "caidhct21sy7lhabvqhauho9", "mapToUserCourse": true, "nextLessonIndex": 8, "progress": 70}
DOM Bundled 172ms node_modules\expo\dom\entry.js (371 modules)
 LOG  [webview] Logs will also appear in the Safari/Chrome debug console
 LOG  Running application "main" with appParams:
 {"hydrate": undefined, "rootTag": "#root"}
Development-level warnings: ON.
Performance optimizations: OFF.
 (NOBRIDGE) LOG  Mapped courseId to userCourseId: vjcisu67y1r7x2kmbvm2rqlh
 (NOBRIDGE) LOG  isMounted is true in playToEnd event handler. Will now invoke onHandleCompleteLesson.
 (NOBRIDGE) LOG  Entered onHandleCompleteLesson
 (NOBRIDGE) LOG  Invoking markLessonAsCompleted with: {"courseId": "caidhct21sy7lhabvqhauho9", "lessonId": "i7kb0qpdpzg104gc42ziqijj", "nextLessonIndex": 9, "progress": 80}
 (NOBRIDGE) LOG  Replacing video in player for lessonIndex 9
 (NOBRIDGE) LOG  In useEffect for updating course progress. externalNavigationRef.current = false
DOM Bundled 16ms node_modules\expo\dom\entry.js (1 module)
 LOG  [webview] Logs will also appear in the Safari/Chrome debug console
 LOG  Running application "main" with appParams:
 {"hydrate": undefined, "rootTag": "#root"}
Development-level warnings: ON.
Performance optimizations: OFF.
 (NOBRIDGE) LOG  Entered updateUserCourseProgress with: {"courseId": "vjcisu67y1r7x2kmbvm2rqlh", "mapToUserCourse": false, "nextLessonIndex": 9, "progress": 80}
 (NOBRIDGE) LOG  Replacing video in player for lessonIndex 9
 (NOBRIDGE) LOG  In useEffect for updating course progress. externalNavigationRef.current = true
 (NOBRIDGE) LOG  Entered updateUserCourseProgress with: {"courseId": "caidhct21sy7lhabvqhauho9", "mapToUserCourse": true, "nextLessonIndex": 9, "progress": 80}
 (NOBRIDGE) LOG  Mapped courseId to userCourseId: vjcisu67y1r7x2kmbvm2rqlh

commit: Added debugging code for video restart and overview.tsx RichtTexTContent initial props not defined possibility

CG provided a useRef flag fix. ... some iterations ... That seems to work. 
Current code in app\(app)\(authenticated)\course\[slug]\[lesson]\index.tsx  (the commented code covers an issue discussed later in this post):
  const lastLessonIndexRef = useRef<string | null>(null);
...
  if (lesson?.video) {
    if (lastLessonIndexRef.current !== lessonIndex) {
      console.log("Replacing video in player for lessonIndex", lessonIndex);
      // if (Platform.OS === 'web') {
      // // Only mutes the audio; Does not autoplay on web
      //   player.muted = true; 
      // }
      player.replace(lesson.video);
      // player.play(); // Does not autoplay on web; Not needed for Android
        // see [expo-video] Autoplay doesn't work for video on Web #36350, 
        // https://github.com/expo/expo/issues/36350
      lastLessonIndexRef.current = lessonIndex;
    } else {
      // Optional debug log:
      console.log("Skipping replace, same lessonIndex", lessonIndex);
    }
  }
----
Commit: Fix for video restart
================

The below message is from some time in the course of above testing. It is noted separately as it is a different error.
Also I got an "Initial props are not defined. This is a bug in the DOM Component runtime" error on mobile.
Metro console data:
 ERROR  The above error occurred in the <DOMComponentRoot> component:

    at DOMComponentRoot (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:36434:58)
    at LogBoxStateSubscription (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:35898:9)
    at ErrorOverlay
    at withDevTools(ErrorOverlay)
    at AppContainer (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:56273:5)

React will try to recreate this component tree from scratch using the error boundary you provided, LogBoxStateSubscription.
 ERROR  Warning: Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously later calls tries to update the component. Move this work to useEffect instead.      
    at LogBoxStateSubscription (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:35898:9)
    at ErrorOverlay
    at withDevTools(ErrorOverlay)
    at AppContainer (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:56273:5)
 (NOBRIDGE) LOG  In useEffect for updating course progress. externalNavigationRef.current = true
 (NOBRIDGE) LOG  Entered updateUserCourseProgress with: {"courseId": "caidhct21sy7lhabvqhauho9", "mapToUserCourse": true, "nextLessonIndex": 4, "progress": 30}
 (NOBRIDGE) LOG  Mapped courseId to userCourseId: vjcisu67y1r7x2kmbvm2rqlh
=======================
Dismissing the error on mobile app leads to app working OK.
So this is a flaky kind of error and may not be related to the restart issue as that happens every time.

This error occurred only once during fair amount of testing. The code in lesson/index.tsx prevents this condition from happening by putting out a text message instead of using RichtTextContent component. Now added similar code in lesson/overview.tsx. 
So when user is in lesson page, this error should not show on app but the text message of "No notes available for this lesson." or "No description for this course." may be shown. If that happens, the cause should be investigated. However, the user will not have an app crash message.

Otherwise the app seems to work correctly on mobile with cloud backend as well as local backend.
==================

On Android device, player.replace(lesson.video); 
seems to cause video to also play.
On Web, it does not automatically play. On pressing play button it plays.

Tried
player.replace(lesson.video);
player.play();

Did not work.

CG: ... this is very likely a browser autoplay-policy issue, not a problem with your React Native or Expo code.
...

Why your code didn’t auto-play on Web

Browsers (especially Chrome, Safari, Edge, etc.) block autoplay with sound by default, unless:

  • The video is muted, or

  • The user has interacted with the page (e.g. clicked anywhere), or

  • The site has earned autoplay permission (for example, via repeated user interactions).

So even though your code calls:

player.replace(lesson.video); player.play();

Chrome may silently reject that .play() call, doing nothing — it won’t throw a visible error, but playback won’t start until the user interacts.

This is standard web media behavior — nothing wrong with your Expo setup.


✅ How to confirm this is the cause

Try this simple check in Chrome DevTools console (after loading your web app):

document.querySelector("video").play();

If you get a warning like: “Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first.”

then it’s definitely autoplay policy blocking you.

...

Me: document.querySelector("video").play();

gets the video to play.

CG: Perfect — that confirms 100% that your code and Expo setup are fine, and the issue is Chrome’s autoplay policy, not your app. 馃幆

So what’s happening is exactly this:

  • Chrome blocks autoplay until a user interacts (e.g. click, tap, or keypress).

  • Your code’s player.play() runs before that first interaction → silently ignored.

  • When you manually run document.querySelector("video").play() from DevTools (a user action), playback starts — proving everything else works.

...

Me: [expo-video] Autoplay doesn't work for video on Web #36350, https://github.com/expo/expo/issues/36350

CG: Excellent — yes, that GitHub issue #36350 is the exact upstream cause of what you’re seeing. 馃幆

Here’s a concise summary of that issue (and what it means for your project):


馃З Issue Summary — “[expo-video] Autoplay doesn’t work for video on Web”

馃搫 Context

  • The issue affects Expo SDK 52+, including your setup.

  • Developers reported that:

    player.play();

    after player.replace() or player.loadAsync() does not start playback on Web, even though it works fine on Android and iOS.

⚠️ Root cause

  • The Expo Video web implementation uses an underlying <video> element.

  • Browsers (especially Chrome and Safari) block autoplay unless:

    • The video is muted, or

    • The call to .play() happens during a user gesture event (click/tap).

  • Expo’s internal player abstraction doesn’t yet fully handle these restrictions on Web — so calling player.play() programmatically after replace() doesn’t count as a “user gesture.”

馃挰 Maintainers confirmed

This is a limitation of browsers’ autoplay policies and not something we can bypass in Expo Video. Developers must trigger playback via a user interaction or start muted.

(Source: Expo GitHub issue #36350)

...

[CG suggested muting the player before player.replace() statement. That only muted the audio and did not play the video. I have left the code related to this in the file in commented form with some explanation comments.]

Based on above, I decided not to invest further time on this issue. It was not worth the time, as of now, to try out some other workarounds suggested in above Expo GitHub issue link.
========

I think a little earlier, I modified code to show confetti on last lesson of course playing to end on mobile

Commit: Confetti shown on last lesson playing to end on mobile; Unsuccessful straightforward attempts to get web app to autoplay video
=========================

18:15, 13 October 2025

Starting next segment: Deploy your Website through EAS Hosting, https://www.youtube.com/watch?v=fO3D8lNs10c&t=16658s
Added +html.tsx and +not-found.tsx from repo (build-failed-lms-react-native folder on PC) to project app folder.

SG says @ https://youtu.be/fO3D8lNs10c?t=16708 that in .env file setting:
EXPO_UNSTABLE_DEPLOY_SERVER=1
allows EAS (hosting) to automatically update app.json expo-router origin (which I presume is to the url the app gets hosted on).
Related entry in app.json:
        "expo-router",
        {
          "origin": "https://fake-devonly-lms.expo.app"
        }
----

I made the above .env setting in .env.cloud.fs
------
[I think I will have to copy .env.cloud.fs to .env before following command.]
npx expo export --platform web

Could I use the following package.json script for the above:
    "export-web": "copy .env .env.back && copy .env.cloud.fs .env && expo export --platform web",

CG was OK with above. Made the change to package.json.
Ran: npm run export-web

That generated a dist folder to which web app is exported. Console output:
lms> npm run export-web

> lms@1.0.0 export-web
> copy .env .env.back && copy .env.cloud.fs .env && expo export --platform web

        1 file(s) copied.
        1 file(s) copied.
env: load .env
env: export EXPO_PUBLIC_STRAPI_API_URL EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY EXPO_PUBLIC_REVENUECAT_WEB_KEY STRAPI_API_ADMIN_TOKEN EXPO_UNSTABLE_DEPLOY_SERVER EXPO_PUBLIC_CLERK_WAITLIST_ON
Starting Metro Bundler
Static rendering is enabled. Learn more: https://docs.expo.dev/router/reference/static-rendering/
位 Bundled 76597ms node_modules\expo-router\node\render.js (1563 modules)
Web Bundled 77370ms node_modules\expo-router\entry.js (1541 modules)
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位  LOG  Main _layout: In RootLayout
位 Bundled 517ms app\api\add-user-course+api.ts (1 module)

› web bundles (2):
_expo/static/css/web-a6992c045c09d22a5a3c0e82d9bf27fd.css (13.4 kB)
_expo/static/js/web/entry-54d2fe2f10d34e94748be459de22f6c5.js (3.69 MB)

› Static routes (13):
/soon (16.8 kB)
/wait (16.8 kB)
/ (index) (16.8 kB)
/login (16.8 kB)
/_sitemap (16.8 kB)
/+not-found (16.8 kB)
/(app)/(authenticated)/courses (16.8 kB)
/(app)/(authenticated)/(tabs) (16.8 kB)
/(app)/(authenticated)/course/[slug] (16.8 kB)
/(app)/(authenticated)/(tabs)/profile (16.8 kB)
/(app)/(authenticated)/(tabs)/my-content (16.8 kB)
/(app)/(authenticated)/course/[slug]/[lesson] (16.8 kB)
/(app)/(authenticated)/course/[slug]/[lesson]/overview (16.8 kB)

› API routes (1):
/api/add-user-course (3.92 kB) (source map (5.86 kB))

Exported: dist
lms> 
===================
As a double-check, I confirmed that .env is same as .env.cloud.fs.
dist folder was created with size of 9.25 MB containing 59 files and 39 folders.
==========================

Created EAS project: SG-EduApp-LMS

On PC console:
eas whoami shows that I am logged in

EAS instructions to link existing local project for an existing codebase: npm install --global eas-cli && eas init --id --snipped--

I think I will run npm install --global eas-cli to update it to latest version. (current version is eas-cli/16.1.0 win32-x64 node-v22.16.0. It says eas-cli@16.12.0 is now available)

Ran: npm install --global eas-cli
That succeeded.
lms> eas --version
eas-cli/16.21.0 win32-x64 node-v22.16.0
lms> 

Ran:
lms> eas init --id --snipped--
√ Project successfully linked (ID: --snipped--) (modified app.json)
√ Project slug (sg-eduapp-lms) does not match the value configured in the "slug" field (lms).. Do you wish to overwrite it? ... yes
lms> 

lms> eas env:push --path .env
√ Select environment: » development
Uploaded env file to development.
lms> 

[I could have used .env.cloud.fs above, I guess. Anyway, at this stage .env is same as .env.cloud.fs.]
-----------
About eas deploy: takes export that was done earlier and deploys to eas hosting
After deployment, SG runs the deployed app, and takes the part of the url before -- , which in his case is supersimon-lms, and changes app.json expo-router origin to:
https://supersimon-lms.expo.app [It opens up the login page! So may be fully active. Would be interesting to try it out later and compare with my app.]
He says that that is how it will be in production environment (what he deploys to eas in the video seems to be development version).
After above change, SG says building iOS app (will apply to Android too, I guess), will use above origin for EXPO API calls. That, IFIRC, matches what CG had said in discussion covered earlier in this post.
Ref: https://youtu.be/fO3D8lNs10c?t=16825
-----------

I ran: ...
lms> eas deploy
EAS Hosting is still in preview and subject to changes.
> Project export: server - exported 3 hours ago
√ Choose a preview URL for your project: ... sg-eduapp-lms.expo.app
✔ Created deployment
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■| 100% Uploading 43 assets

馃帀 Your deployment is ready

Dashboard       https://expo.dev/projects/--snipped--/hosting/deployments

馃殌 When you are ready to deploy to production:
  $ eas deploy --prod
lms> 
-----------

It seems to be working OK!

That was super smooth! No trip-ups at all!

Commit: Deployed frontend to EAS; EAS app is working with STRAPI cloud backend
===============================

Comments