A self-hosted 6-month Mandarin study tracker: daily checklist, vocab log, flashcards (spaced repetition on your own vocab), AI sentence correction ("reverse practice"), saved speaking-practice audio, a 六个月历 calendar styled after 田字格 character-practice paper, resource library, the full week-by-week roadmap, stats, and a PIN-protected login.
Two parts:
- backend/ — Node.js + Express API, backed by MongoDB
- frontend/ — React (Vite) app, deployed as a static site
This is built to be genuinely simple: one database, one small API, one static frontend. No extra services required beyond a free MongoDB Atlas cluster and two free hosting accounts.
- Go to https://www.mongodb.com/cloud/atlas/register and create a free account.
- Create a new project, then build a free M0 cluster (no credit card needed).
- Under Database Access, create a database user with a username and password.
- Under Network Access, add
0.0.0.0/0(allow access from anywhere) — simplest option for a small personal app; your data is still protected by the PIN and by your database password. - Click Connect > Drivers, copy the connection string. It looks like:
mongodb+srv://<username>:<password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majorityAdd a database name before the?, e.g..../ziji?retryWrites=true...
Keep this string — it's your MONGODB_URI.
- Go to https://aistudio.google.com/apikey and sign in with a Google account.
- Click Create API key — no credit card needed.
- Copy the key. This is your
GEMINI_API_KEY. The free tier (gemini-2.5-flash, 250 requests/day) is far more than a daily practice habit will use.
- Go to https://cloudinary.com/users/register/free and create a free account.
- On your dashboard, copy the Cloud name — this is your
CLOUDINARY_CLOUD_NAME(used by both backend and frontend). - Go to Settings > Upload > Upload presets > Add upload preset. Set
Signing Mode to Unsigned, save, and copy the preset name — this is
your
CLOUDINARY_UPLOAD_PRESET(frontend only; it's not a secret). - Back on the dashboard, copy your API Key and API Secret — these are
CLOUDINARY_API_KEYandCLOUDINARY_API_SECRET(backend only, used just to delete clips when you remove them).
cd backend
cp .env.example .env
# edit .env: paste MONGODB_URI, GEMINI_API_KEY, Cloudinary values, generate a JWT_SECRET,
# set FRONTEND_URL=http://localhost:5173
npm install
npm startYou should see MongoDB connected and Server running on port 4000.
Generate a random JWT_SECRET with:
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"cd frontend
cp .env.example .env
# edit .env: VITE_API_URL=http://localhost:4000, plus your Cloudinary cloud name + preset
npm install
npm run devOpen the printed local URL. First launch will ask you to create a PIN — this is enforced by the backend, not just the browser, so it's real access control once deployed.
- Push this whole folder to a GitHub repo.
- Go to https://render.com, sign in with GitHub.
- New > Web Service, pick your repo, set:
- Root directory:
backend - Build command:
npm install - Start command:
npm start
- Root directory:
- Add environment variables (same as your
.env):MONGODB_URI,JWT_SECRET,GEMINI_API_KEY,CLOUDINARY_CLOUD_NAME,CLOUDINARY_API_KEY,CLOUDINARY_API_SECRET,FRONTEND_URL(fill this in after step 7, then redeploy). - Deploy. Note the URL Render gives you, e.g.
https://ziji-backend.onrender.com.
Render's free tier sleeps after inactivity and wakes on the next request (a few seconds' delay) — fine for a personal daily-use app.
Vercel:
- Go to https://vercel.com, import the same GitHub repo.
- Set root directory to
frontend. - Add environment variables:
VITE_API_URL(your Render backend URL from step 6),VITE_CLOUDINARY_CLOUD_NAME,VITE_CLOUDINARY_UPLOAD_PRESET. - Deploy. Vercel gives you a URL like
https://ziji.vercel.app.
Netlify works the same way — root directory frontend, build command
npm run build, publish directory dist, same environment variables.
- Go back to Render and set
FRONTEND_URLto your new Vercel/Netlify URL, then redeploy the backend, so it only accepts requests from your app.
Open your Vercel/Netlify URL from any device, any browser — phone, laptop, doesn't matter. First time, you'll create a PIN. After that, log in with it; your session stays active in that browser until you tap Log out in Settings.
Your data lives in your MongoDB Atlas cluster (text/notes/progress) and Cloudinary (audio), so it's the same data no matter which device you open the app from.
- The PIN is hashed (bcrypt) and checked server-side — a real gate, not just hiding UI in the browser.
- This is still a lightweight, single-user setup: good for keeping a personal tracker private, not built for handling sensitive financial or legal data.
- Use the Export backup button in Stats occasionally if you want a local copy outside the database, just for peace of mind (note: this exports your text data — journal, vocab, links, days — not the audio clips themselves, since those already live safely in Cloudinary).
Reverse practice sends whatever you type to Gemini for correction and a short explanation, then logs it so you can see your practice history build up over time in the Practice tab and in Stats. Nothing is sent anywhere except Google's API, using your own key.
Flashcards run a simple Leitner-style spaced repetition on your Vocab log directly — no separate data entry needed. Mark a card "Got it" and it comes back less often (up to every 14 days); mark it "Still learning" and it resets to daily review. "Words mastered" in Stats counts words that have reached the top box.
Speaking practice audio now actually persists: recordings upload directly from your browser to Cloudinary (your account, your free tier — 25 credits/month, plenty for short clips), and only the resulting link is stored in your database. Removing a clip deletes it from Cloudinary too, not just from the list. Cloudinary's free tier is generous but not infinite — if you record daily for 6 months, keep an eye on your dashboard usage and delete clips you no longer need.