Welcome to the official customization documentation for Bookly. Please follow the setup guide below before modifying the source code.
For optimal compatibility and performance, we highly recommend using the exact versions listed below.
Select your operating system below to view the setup instructions.
android-studio-ide-<version>-windows.exe) and follow the installation wizard.C:\flutter).flutter\bin directory path.flutter doctor to verify installation.tar xf flutter_macos_<version>.tar.xzsudo mv flutter /YOUR_DIRECTORYflutter/bin directory to your system's PATH variable in your shell profile (e.g., `.zshrc` or `.bash_profile`).flutter doctor to verify installation.tar -xvzf android-studio-ide-<version>-linux.tar.gzsudo mv android-studio /YOUR_DIRECTORYcd /YOUR_DIRECTORY/android-studio/bin) and run ./studio.sh to start the setup wizard.tar xf flutter_linux_<version>.tar.xzsudo mv flutter /YOUR_DIRECTORYflutter/bin directory to your system's PATH variable in your shell profile.flutter doctor to verify installation.First, you have to find out the existing applicationId. You can find it out from the top of the /android/app/build.gradle.kts file.
Now select the application ID and press command + shift + r or for Windows ctrl + shift + r, place your preferred package name in the replace input box, and then click on the Replace All button.
build.gradle or others.
google-services.json file.<project>/android/app/google-services.json.
bundleId and click Register app.GoogleService-Info.plist file.<project>/ios/Runner/GoogleService-Info.plist.
If you prefer a visual guide, you can also watch the videos below for a complete setup walkthrough:
You need to set your app name in three different places: within your application's localization files, Android manifest, and iOS configuration file.
<project>/lib/core/localization.en_us.dart, find app_name, and replace the value "Bookly" with your preferred app name.bn_bd.dart and ar_sa.dart, placing your preferred app name in the respective files.
<project>/android/app/src/main/AndroidManifest.xml.android:label property.android:label="YOUR_APP_NAME"
<project>/ios/Runner/Info.plist.CFBundleName key.<key>CFBundleName</key>
<string>YOUR_APP_NAME</string>
To change the App logo and App Icon, you need to follow these steps:
<project>/assets/images/ and replace bookly_logo.png and place_holder_image.jpg with your own images.
You can generate your app icon using the tools shown in this tutorial:
/android/app/src/main/res and replace all mipmap folders with your <generated icon>/android folder./ios/Runner and replace the existing Assets.xcassets folder with your generated Assets.xcassets folder.To connect the Bookly app to your own backend server, you must update the API endpoint configurations.
<project>/lib/core/network and open the api_endpoints.dart file.baseUrl and searchApi strings with your own backend URLs.homeApi, categoriesApi, etc.) to match your backend routing structure.Bookly supports multi-language capabilities. You can easily add new languages, remove existing ones, or set a default app language by following these steps.
<project>/lib/core/localization/translations.fr_fr.dart).en_us.dart) into your new file.enUS to frFR), and translate all the string values on the right side of the colons to your new language.
<project>/lib/core/localization/config/language_config.dart.languages list inside the LanguageConfig class.LanguageModel block to this list, filling in your language name, country code, and referencing the translation map variable you just created.
languages list as the default.LanguageModel to the very top of the list in language_config.dart.language_config.dart and delete its LanguageModel from the list.translations folder.To start earning revenue through ads, you need to integrate your own AdMob Unit IDs for both Android and iOS.
The app configuration uses a conditional check (kDebugMode) to automatically switch between Test IDs (during development) and Production IDs (when live).
<project>/lib/core/config/app_config.dart.AppConfig class.:) with your actual AdMob Unit IDs for Banner and Rewarded ads. Leave the first string as the test ID.
In addition to the Unit IDs, you must configure your AdMob Application IDs in the native Android and iOS configuration files.
<project>/android/app/build.gradle.kts.buildTypes section.getByName("release") block, locate manifestPlaceholders["admobAppId"] and replace the value with your actual Android AdMob App ID. (You can leave the one under the "debug" block as the default Google test ID).
<project>/ios/Runner/Info.plist.GADApplicationIdentifier key and replace the string value directly below it with your actual iOS AdMob App ID.<key>GADApplicationIdentifier</key>
<string>YOUR_IOS_ADMOB_APP_ID</string>
Bookly uses RevenueCat to handle In-App Purchases (subscriptions and one-time purchases) seamlessly across Android and iOS platforms. Follow these steps to configure your products.
<project>/lib/core/config/app_config.dart.revenuecatAndroidKey and revenuecatIOSKey values with your actual API keys from your RevenueCat dashboard.
app_config.dart file (e.g., bookly_subscriptions and bookly_onetime).
Run the following command to build the APK:
flutter build apk
This command will compile your Flutter code and generate the APK file. You can find the APK in the build/app/outputs/flutter-apk directory inside your project folder. The APK file will be named something like app-release.apk or app-debug.apk.
Before deploying to the Play Store, additional configuration is needed. You must create an upload keystore.
1. Create an upload keystore
Run the following command at the command line. On macOS or Linux, use the following command:
keytool -genkey -v -keystore ~/upload-keystore.jks -keyalg RSA \
-keysize 2048 -validity 10000 -alias upload
On Windows, use the following command in PowerShell:
keytool -genkey -v -keystore $env:USERPROFILE\upload-keystore.jks `
-storetype JKS -keyalg RSA -keysize 2048 -validity 10000 `
-alias upload
2. Replace the Keystore File
After generating upload-keystore.jks, replace the existing project bookly-keystore.jks with your new upload-keystore.jks in the <project>/android/app directory.
3. Update Key Properties
Update storePassword, keyPassword, and storeFile with your own credentials in <project>/android/key.properties.
4. Update App Version
Update your app version in <project>/android/app/build.gradle.kts.
5. Generate the App Bundle
Now you have completed all the release configurations. Generate the app bundle using the following command:
flutter build appbundle
After a successful build, you will find your app bundle in <project>/build/app/outputs/bundle/release/app-release.aab. Now you can upload it to your Google Play Console.
There is no general way to generate apps for iOS locally without the Apple ecosystem. Apple doesn’t allow you to install apps via direct download. If you want to install it on your iOS device for testing, you can run it directly using Xcode. For deploying to production, please follow the official Flutter documentation here: https://docs.flutter.dev/deployment/ios
flutter devices
This lists your iOS device (e.g., iPhone 14 (ios)). If the device doesn’t appear, check the USB connection and ensure Xcode recognizes the device.flutter run
If multiple devices are connected, specify the device:
flutter run -d iPhone-14
flutter devices to confirm detection (e.g., iPhone 13 (ios)).flutter run -d iPhone-13
The backend powers both the mobile app and the admin panel. Set this up first — the app and admin panel cannot run without it.
npm ci
createdb bookly
psql:
CREATE DATABASE bookly;
cp .env.example .env
.env and fill in the required values below. Every other variable is optional — the matching feature simply stays switched off until you configure it.| Variable | What to set |
|---|---|
DB_HOSTDB_PORTDB_USERDB_PASSWORDDB_NAME |
Your PostgreSQL connection details. |
BASE_URL |
The public address of this API, with no trailing slash. Example: https://api.your-domain.com |
JWT_SECRETREFRESH_TOKEN_SECRET |
Two different long random strings. Generate each with:openssl rand -hex 48 |
BOOTSTRAP_ADMIN_EMAILBOOTSTRAP_ADMIN_PASSWORD |
The first admin account. This is what you sign into the admin panel with. |
DB_SYNC |
Set to true for the very first run so the database tables are created for you. |
APP_LOCALES |
Active content languages, comma separated. Supported: en, bn, ar, hi |
JWT_SECRET and REFRESH_TOKEN_SECRET sign your users' login tokens. If you leave the placeholder values in place, anyone can forge a login for any account, including an administrator.
npm run start:dev
Superadmin bootstrap complete for admin@your-domain.com
http://localhost:5008/api/docs to confirm the API is running. You should see the Swagger documentation page.Once you have signed into the admin panel successfully, make these three changes in .env and restart the server:
DB_SYNC=false. Leaving it on lets schema changes be applied automatically, which you do not want on a live database.BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD lines. They are only needed once.Book covers, avatars, banners and the book files themselves (EPUB/PDF) need somewhere to live. The backend picks a storage driver automatically — the first fully configured one wins:
uploads/ folder and are served from /uploads/. No configuration needed, which is why a fresh install works straight away.To use Cloudflare R2, fill in all of these in .env:
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_R2_ACCESS_KEY_ID=
CLOUDFLARE_R2_SECRET_ACCESS_KEY=
CLOUDFLARE_R2_BUCKET=
CLOUDFLARE_R2_ENDPOINT=
CLOUDFLARE_R2_PUBLIC_URL=
uploads/ folder, check that all six values are filled in — especially CLOUDFLARE_R2_PUBLIC_URL, which is easy to overlook.
All of these are switched off by default. The server starts and runs normally with every one of them unconfigured, so set up only what you need.
| Feature | Variables | Notes |
|---|---|---|
| Push notifications Firebase sign-in |
FIREBASE_PROJECT_IDFIREBASE_CLIENT_EMAILFIREBASE_PRIVATE_KEY |
Taken from your Firebase service-account JSON (Project settings → Service accounts). Use the same Firebase project you set up for the app in section 4. |
| Email (OTP, password reset) | MAIL_ENABLEDSMTP_HOST, SMTP_PORTSMTP_USER, SMTP_PASSSMTP_FROM |
Set MAIL_ENABLED=true to turn it on. Use SMTP_SECURE=true for port 465, false for 587. |
| PipraPay payments | PIPRAPAY_ENABLEDPIPRAPAY_API_KEYPIPRAPAY_RETURN_URLPIPRAPAY_WEBHOOK_URL |
Set PIPRAPAY_ENABLED=true to turn it on. |
| In-app purchase receipts | GOOGLE_PLAY_PACKAGE_NAMEAPPLE_BUNDLE_ID, APPLE_ISSUER_IDAPPLE_KEY_ID, APPLE_PRIVATE_KEY |
Needed only if you sell through the App Store / Play Store. See section 11. |
| Background jobs | PG_BOSS_ENABLEDPG_BOSS_SCHEMA |
Used for scheduled work such as rental-expiry reminders. Without it the app falls back to in-process scheduling, which is fine for a single server. |
\n escape sequences exactly as they appear in the JSON file:
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n"
If your hosting panel mangles that, use FIREBASE_PRIVATE_KEY_BASE64 instead and paste the same key base64-encoded.
npm ci
npm run build
prod.config.js and change cwd to the folder you deployed into.pm2 start prod.config.js --env production
pm2 logs bookly_backend --lines 30
prod.config.js contains no credentials by design. The app reads them from the .env file sitting in the folder named by cwd, so make sure your .env is uploaded there.
If you serve the API through nginx, use the settings below. Both matter:
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:5008;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
client_max_body_size must be at least as large as your HTTP_BODY_LIMIT (default 50mb), otherwise book uploads fail with a 413 error.X-Forwarded-For header lets the backend see each visitor's real IP address. Without it every visitor looks like the same person to the rate limiter, and they all get blocked together.These are enabled out of the box. You may want to tune them for your deployment:
| Variable | Default | What it does |
|---|---|---|
CORS_ORIGINS |
empty | Comma-separated list of web addresses allowed to call the API. Empty means any origin is allowed — fine locally, but in production set it to your admin panel's address. Mobile apps are unaffected either way. |
RATE_LIMIT_MAXRATE_LIMIT_TTL |
120 per 60s | General request limit per visitor. |
AUTH_RATE_LIMIT_MAXAUTH_RATE_LIMIT_TTL |
20 per 60s | Tighter limit on login, registration, OTP and password reset, to stop password-guessing attacks. |
PROTECT_BOOK_FILES |
false |
Set to true to add an expiring signature to book file links, so a link that leaks cannot be reused. Your apps need no changes for this — the signature travels inside the link. |
| Problem | Solution |
|---|---|
| Warning at startup: Firebase is not configured | Normal if you have not set up push notifications. The server runs fine. Fill in the FIREBASE_* values to remove it. |
| Cannot sign into the admin panel | The admin account is only created when the database is completely empty. Check the startup logs for the Superadmin bootstrap complete line. |
| No tables were created | DB_SYNC=true is required for the first run. |
| 413 error when uploading a book | Raise client_max_body_size in nginx (see step 9). |
| 429 error when logging in | Rate limit reached. Wait a minute, raise AUTH_RATE_LIMIT_MAX, or check that nginx forwards X-Forwarded-For. |
| CORS errors in the admin panel | Add the admin panel's address to CORS_ORIGINS. |
| Files are not going to Cloudflare R2 | One of the six R2 values is missing. See the warning in step 6. |
| Swagger shows the wrong address | Set SWAGGER_SERVER_URL to your public API address. |
The admin panel is where you manage books, authors, users, subscriptions and everything else in your store. Complete the backend setup (section 13) first — the panel is only a front-end for that API.
npm ci
cp .env.example .env
.env and set the three required values:| Variable | What to set |
|---|---|
NEXT_PUBLIC_API_URL |
Your backend address including /api/v1.Example: https://api.your-domain.com/api/v1 |
NEXT_PUBLIC_IMAGE_URL |
The address your uploaded files are served from, without any /uploads ending.Local storage: https://api.your-domain.comCloudflare R2 / Bunny: your CDN address |
NEXT_PUBLIC_APP_LOCALES |
Active languages, comma separated. Must match the backend's APP_LOCALES exactly.Example: en,bn,ar,hi |
NEXT_PUBLIC_API_URL must end in /api/v1. Endpoint names are added directly onto this address, so if you leave the version off, every single request returns a 404 and the panel appears completely broken.NEXT_PUBLIC_IMAGE_URL must not end in /uploads. The file paths already contain it, so adding it here produces addresses like .../uploads/uploads/cover.jpg and no images load.NEXT_PUBLIC_ is built into the browser files, so never put a password or secret key in this file. You must also run npm run build again after changing any of them — editing .env alone will not update an already-built panel.
npm run dev
http://localhost:3000.BOOTSTRAP_ADMIN_EMAIL and BOOTSTRAP_ADMIN_PASSWORD you set in the backend's .env file.npm ci
npm run build
npm run start
This runs on port 4000. To use a different port, edit the start script in package.json (for example next start -p 8080).
pm2 start prod.config.js --env production
Remember to set cwd in prod.config.js to your deployment folder first.
CORS_ORIGINS in the backend's .env and restart the backend. Without this the browser blocks every request and the panel will not load any data.
CORS_ORIGINS=https://admin.your-domain.com
| Section | What it covers |
|---|---|
| Dashboard | Totals for users, books, authors, publishers and categories. |
| Books | Add and edit books, upload EPUB/PDF files and covers, set pricing, publish or unpublish. |
| Authors / Publishers / Catalog | Manage the people and categories behind your catalogue. |
| Users | View and manage accounts and their roles. |
| Subscription Plans & Prices | Set up your plans, pricing and rental durations. |
| Book Sales / Subscription Sales | Review orders and active subscriptions. |
| Home & Banners | Arrange the app home screen sections and promotional banners. |
| Notifications | Send push notifications to your users (requires Firebase). |
| Reviews | Moderate reader reviews and comments. |
| Legal Pages | Edit your Privacy Policy and Terms, which the app displays. |
| App Version | Control the in-app update prompt. |
Books, authors, publishers and categories can hold a separate title and description per language. The language tabs shown in the panel come from NEXT_PUBLIC_APP_LOCALES.
NEXT_PUBLIC_APP_LOCALES and the backend's APP_LOCALES do not match, you may enter content in a language the backend then discards.
| Problem | Solution |
|---|---|
| Every page is empty and all requests return 404 | NEXT_PUBLIC_API_URL is missing the /api/v1 ending. Fix it, then run npm run build again. |
| Images and covers do not load | Either NEXT_PUBLIC_IMAGE_URL wrongly ends in /uploads, or your image host is not allowed. For a separate CDN, add its hostname to NEXT_PUBLIC_EXTRA_IMAGE_HOSTS. |
| Browser console shows CORS errors | Add the panel's address to CORS_ORIGINS in the backend .env and restart the backend. |
| Login says the details are wrong | Use the BOOTSTRAP_ADMIN_* details from the backend .env. Confirm the backend created the account by checking its startup logs. |
| Too many login attempts / 429 | The backend rate limiter has kicked in. Wait a minute and try again. |
Changing .env has no effect |
Settings are baked in when the panel is built. Run npm run build and restart. |
| Signed out unexpectedly | The login token has expired. Sign in again, or increase JWT_EXPIRES_IN in the backend .env. |
This section covers the request and response formats you will work with most often. The complete, always-current reference for all 166 endpoints is the Swagger UI served by your own backend:
http://localhost:5008/api/docs
Swagger is generated from the source code, so it can never drift out of date. Use Authorize there to paste a token and try any endpoint live.
| Item | Value |
|---|---|
| Base path | /api |
| Version | In the URL: /api/v1/... |
| Auth header | Authorization: Bearer <access_token> |
| Content type | application/json (uploads use multipart/form-data) |
| IDs | UUID v4 |
| Timestamps | ISO 8601 UTC, e.g. 2026-09-19T10:30:00.000Z |
Every response — success or failure — uses the same outer shape.
{
"success": true,
"status_code": 200,
"data": { }
}
List endpoints add a pagination block. Some also add counts, which are totals across the whole resource and are not affected by your current search filter.
{
"success": true,
"status_code": 200,
"data": [ ],
"pagination": {
"total": 120,
"page": 1,
"limit": 20,
"totalPages": 6,
"has_next": true,
"has_prev": false,
"counts": {
"total_non_deleted": 120,
"published_live": 98,
"draft": 15,
"pending": 7
}
}
}
Common list query parameters: page, limit, search, sort, status.
{
"success": false,
"status_code": 401,
"error": "Unauthorized",
"message": "Authorization token is missing. Send a Bearer token in the Authorization header.",
"timestamp": "2026-09-19T10:30:00.000Z"
}
Validation failures return message as an array of strings, one per invalid field:
{
"success": false,
"status_code": 400,
"error": "Validation Error",
"message": [
"fullName must be longer than or equal to 2 characters",
"otp_token must be a string"
],
"timestamp": "2026-09-19T10:30:00.000Z"
}
success first, then read status_code. Remember that message is a string for normal errors but an array for validation errors — handle both.
| Code | Meaning |
|---|---|
200 / 201 | Success |
400 | Validation failed, or a feature is not configured (e.g. SMTP) |
401 | Missing, invalid or expired token |
403 | Signed in, but not allowed — or an expired signed book link |
404 | Not found |
409 | Conflict, e.g. the email is already registered |
413 | Upload too large — see the nginx note in section 13 |
429 | Rate limit reached |
Titles, names and descriptions are objects keyed by language code, not plain strings. Which languages are accepted comes from APP_LOCALES.
{
"title": {
"en": "Moby Dick",
"bn": "মোবি ডিক",
"ar": "موبي ديك",
"hi": "मोबी डिक"
}
}
You do not have to send every language. Any you leave out come back as an empty string.
Registration is protected by an email one-time code, so it takes three calls.
400 "SMTP not configured" until you set MAIL_ENABLED=true and fill in the SMTP_* values (section 13, step 7). Existing accounts can still log in without SMTP — only new registrations are blocked.
Step 1 — request a code. POST /api/v1/auth/otp/send
{
"email": "jane.doe@example.com",
"purpose": "register"
}
purpose is one of register, forgot_password or email_verify.
Step 2 — verify the code. POST /api/v1/auth/otp/verify
{
"email": "jane.doe@example.com",
"purpose": "register",
"otp": "482913"
}
The response contains an otp_token. It is short-lived — its lifetime is OTP_TOKEN_EXPIRES_IN, 10 minutes by default.
Step 3 — create the account. POST /api/v1/auth/register
{
"fullName": "Jane Doe",
"email": "jane.doe@example.com",
"password": "StrongPa$$123",
"otp_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"device_id": "9774d56d682e549c",
"fcm_token": "fKx9...",
"platform": "android",
"device_name": "Google Pixel 8",
"app_version": "1.0.0"
}
fullName in camelCase, while most other fields use snake_case. Sending full_name returns a validation error.
device_* and fcm_token fields are optional. Send them to register the device for push notifications.
POST /api/v1/auth/login
{
"email": "jane.doe@example.com",
"password": "StrongPa$$123"
}
Response:
{
"success": true,
"status_code": 201,
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"sub": "84138aab-093d-467d-834d-b902a2738c71",
"email": "jane.doe@example.com",
"roles": ["user"]
},
"subscription": {
"is_active": false,
"plan": "free",
"subscription_id": null,
"plan_id": null,
"plan_slug": null,
"plan_name": null,
"status": null,
"billing_cycle": null,
"starts_at": null,
"expires_at": null,
"gateway": null,
"can_read_subscription_books": false
}
}
}
user.sub is the user's UUID. Store both tokens; send the access token on every request.
Access tokens expire after JWT_EXPIRES_IN (4 hours by default). When a request returns 401, exchange your refresh token for a new pair rather than forcing the user to log in again.
POST /api/v1/auth/refresh
{
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Other auth endpoints: POST /auth/logout, POST /auth/forgot-password, POST /auth/reset-password, POST /auth/change-password, POST /auth/firebase (social sign-in).
GET /api/v1/books?page=1&limit=20&search=moby — returns the paginated envelope shown above.
GET /api/v1/books/{id} — send the user's token so the response reflects what that reader is entitled to.
{
"success": true,
"status_code": 200,
"data": {
"id": "9e3fffc1-edb0-4390-b33e-52a3fd239ef5",
"title": { "en": "Moby Dick", "bn": "মোবি ডিক" },
"description": { "en": "..." },
"author": {
"id": "…", "name": { "en": "Herman Melville" },
"image_url": "https://api.example.com/uploads/authors/x.jpg",
"total_books": 12
},
"file_path": "https://api.example.com/uploads/books/x.epub",
"preview_file_path": "",
"cover_url": "https://api.example.com/uploads/covers/x.jpg",
"total_chapters": 135,
"is_favorite": false,
"language": "English",
"dominant_color": "#2B3A42",
"categories": [ { "id": "…", "name": { "en": "Fiction" } } ],
"access_control": {
"can_access": true,
"access_type": "purchase",
"purchased_at": "2026-09-01T10:00:00.000Z",
"expires_at": null,
"is_free": false,
"is_ads_supported": false,
"is_subscribable": true,
"is_rentable": true,
"is_lifetime_purchasable": true
},
"pricing": {
"currency": "USD",
"rent_price": 2.99,
"rent_discount_percent": 0,
"lifetime_price": 9.99,
"lifetime_discount_percent": 0,
"rent_duration_in_days": 30
},
"review": { }
}
}
file_path may be an empty string. The book file link is only included for a reader who is entitled to that title — one who bought it, rented it, or has an active subscription — plus anyone at all for free and ad-supported books. For everyone else it is "", which is the same value a book with no file uploaded yet returns.
access_control.can_access to decide whether to show the reader or the purchase screen. Do not treat an empty file_path as an error.
Admin endpoints need a token belonging to a user with the admin role. Multilingual fields are sent as objects.
POST /api/v1/categories
{
"name": { "en": "Fiction", "bn": "কল্পকাহিনী" },
"description": { "en": "Novels and short stories" },
"slug": "fiction"
}
Response:
{
"success": true,
"status_code": 201,
"data": {
"id": "e00f2495-11d0-4bc0-abc4-5cd3ceb0f440",
"type": null,
"name": { "en": "Fiction", "bn": "কল্পকাহিনী", "ar": "" },
"thumbnail": null,
"color": null,
"description": { "en": "Novels and short stories", "bn": "", "ar": "" },
"total_books": 0,
"see_all": false,
"books": []
}
}
Uploads use multipart/form-data, not JSON.
POST /api/v1/uploads/file
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: <binary>
Limits: images up to 10 MB, EPUB/PDF up to 100 MB. Exceeding the limit returns 413 — if that happens on a server, raise client_max_body_size in nginx as well (section 13).
| Prefix | Purpose |
|---|---|
/auth | Register, login, refresh, OTP, password reset |
/users | Profiles, roles, admin user management |
/books | Catalogue, uploads, publishing workflow |
/authors, /publishers, /categories, /tags | Catalogue metadata |
/book-access | What a reader may open |
/orders, /payments | Purchases, rentals, gateways |
/subscriptions, /subscription-plans | Plans and active subscriptions |
/reading | Progress, sessions, highlights |
/engagement | Reviews and comments |
/home, /home-sections, /home-banner | App home screen and admin dashboard |
/notifications | In-app records and push |
/legal-pages, /app-version | Policy pages and the update prompt |
Bookly uses PostgreSQL with 44 tables. You normally never touch them directly — the backend creates everything on first run — but this section is here for anyone integrating with the data or reviewing the structure before deploying.
DB_SYNC=true for the first run and the backend builds the entire schema from its entity definitions. See section 13, step 4.
A complete, ready-to-run SQL file ships with the backend at database/schema.sql. Use it if you prefer to create the schema manually, or to review it up front:
createdb bookly
psql -d bookly -f database/schema.sql
Set DB_SYNC=false in your .env afterwards, so the backend does not try to manage the schema as well.
id (UUID v4), is_deleted, created_at and updated_at.is_deleted = true rather than removed, so nothing is lost by accident.snake_case.jsonb, e.g. {"en": "Moby Dick", "bn": "মোবি ডিক"}.numeric, never floating point.timestamptz (UTC).| Area | Tables |
|---|---|
| Accounts | users, roles, user_roles, user_devices, email_otps |
| Catalogue | books, authors, publishers, categories, tags, book_authors, book_categories, book_publishers, book_tags |
| Access & commerce | book_access, orders, order_items, payment_products, book_payment_products, payment_transactions, payment_gateway_configs, payment_gateway_regions |
| Subscriptions | subscriptions, subscription_plans, subscription_plan_payment_products, subscription_event_logs |
| Reading | reading_progress, reading_sessions, highlights, book_favorites |
| Engagement | reviews, review_votes, comments, comment_likes |
| Requests | audiobook_requests, author_requests |
| Merchandising | home_banners, home_sections, legal_pages, app_version_configs |
| Notifications | notifications, notification_broadcasts, scheduled_notifications |
| Audit | admin_activity_logs |
users| Column | Type | Notes |
|---|---|---|
id | uuid | Primary key |
full_name | varchar | Required |
email | varchar | Required, unique |
password_hash | varchar | bcrypt. Never returned by the API |
refresh_token_hash | varchar | Hashed refresh token. Never returned by the API |
auth_provider | enum | email, or a social provider |
firebase_uid | varchar | Set for Firebase sign-in |
subscription_plan | enum | free or premium |
preferred_language | varchar | Locale code |
is_active | boolean | Deactivated accounts cannot log in |
Roles are many-to-many via user_roles → roles, so one account can hold several roles.
books| Column | Type | Notes |
|---|---|---|
title | jsonb | Required, multilingual |
description | jsonb | Multilingual |
file_url | varchar | EPUB location |
pdf_url | varchar | PDF, used when there is no EPUB |
sample_file_url | varchar | Free preview |
cover_image_url | varchar | Cover image |
dominant_color | varchar | Hex colour used by the app's reader theme |
status | enum | See the publishing states below |
is_free, is_ads_supported, is_subscribable, is_rentable, is_lifetime_purchasable | boolean | How the book may be obtained |
base_price, rent_price | numeric | Lifetime and rental pricing |
rent_duration_in_days | integer | Rental length |
book_access — who may read whatThis is the table the reader's entitlement check reads. A row grants one user access to one book.
| Column | Notes |
|---|---|
user_id, book_id | Who, and which book |
access_type | free, subscription or single_buy |
granted_at | When access started |
expires_at | NULL means permanent; a date means it is a rental |
order_id, subscription_id | What paid for it |
is_active | Access can be revoked without deleting the row |
These are real PostgreSQL enum types, so the database itself rejects anything outside the list.
| Type | Values |
|---|---|
| Book status | draft · pending · pending_review · published · scheduled · rejected · archived |
| Book access type | free · subscription · single_buy |
| Order kind | book · subscription |
| Order / transaction status | pending · paid · failed · refunded |
| Billing cycle | weekly · monthly · six_monthly · yearly |
| Package billing type | one_time · recurring |
| Purchase type | rent · lifetime · subscription_weekly · subscription_monthly · subscription_six_monthly · subscription_yearly |
| Payment gateway | piprapay · bkash · nagad · sslcommerz · google_play · apple · stripe · revenuecat |
| Highlight type | highlight · bookmark · note |
| Banner type | hero · feature · popup |
| OTP purpose | register · forgot_password · email_verify |
| Comment status | active · hidden · flagged · deleted |
| App version platform | android · ios |
# Back up
pg_dump -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" > bookly-backup.sql
# Restore
psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -f bookly-backup.sql
uploads/ folder alongside the database or your restored catalogue will have no files. With Cloudflare R2 or Bunny the files already live in your bucket.
The problems buyers hit most often, and what fixes them. If your issue is not here, please get in touch — we are happy to help.
java -version. Other major versions are the single most common cause of Android build errors.flutter --version.node -v, for the backend and admin panel.psql --version.flutter doctor and resolve anything it flags.Your Java version is wrong. Install Java 21.0.9 and point Android Studio at it in Settings → Build, Execution, Deployment → Build Tools → Gradle → Gradle JDK. Then:
flutter clean
flutter pub get
google-services.json is missing, or its package name does not match your app. Download a fresh one from your Firebase project and place it at android/app/google-services.json. The package_name inside it must equal the applicationId in android/app/build.gradle.kts. See section 4.
Usually a network or cache problem. Clear the caches and retry:
cd android
./gradlew clean
cd ..
flutter clean
flutter pub get
If it still fails, delete the Gradle cache (~/.gradle/caches) and let it download again.
flutter pub getflutter clean
rm pubspec.lock
flutter pub get
The lock file ships with the package so you get the exact versions we tested. Only delete it if you have deliberately changed a dependency.
Open the project with the bundled Gradle wrapper rather than a system Gradle. Always open the project root in Android Studio, not the android/ folder on its own.
cd ios
pod repo update
pod install --repo-update
cd ..
flutter clean
flutter pub get
Requires CocoaPods 1.16.2 or newer (pod --version). On Apple Silicon, if pod install fails try arch -x86_64 pod install.
Open ios/Runner.xcworkspace in Xcode, select the Runner target, go to Signing & Capabilities and choose your Apple developer team. See section 12.
It cannot reach the backend. Check in this order:
/api/docs in a browser.localhost means the phone. Use your computer's LAN IP or a public address.Registration sends an email one-time code, so it needs a working mail server. Set MAIL_ENABLED=true and fill in the SMTP_* values (section 13, step 7). Existing accounts can still log in without SMTP — only new sign-ups are blocked.
DB_SYNC=true is required on the first run. Alternatively load database/schema.sql by hand (section 16).
The first admin is only created when the database has no users at all. If you have already created one, the bootstrap is skipped by design. Either use the original account, or start from an empty database.
Expected when you have not set up push notifications. The server runs normally; push and Firebase sign-in are simply disabled until you add the FIREBASE_* values.
# See what is using the port
lsof -i :5008
# Stop it, or pick another port in .env
PORT=5009
uploads/ instead of Cloudflare R2R2 needs all of CLOUDFLARE_R2_ACCESS_KEY_ID, CLOUDFLARE_R2_SECRET_ACCESS_KEY, CLOUDFLARE_R2_BUCKET, CLOUDFLARE_R2_ENDPOINT and CLOUDFLARE_R2_PUBLIC_URL. If one is missing it falls back to local disk without an error message. CLOUDFLARE_R2_PUBLIC_URL is the one people forget.
Raise client_max_body_size in nginx to at least your HTTP_BODY_LIMIT (section 13, step 9), then reload nginx.
The brute-force limiter. Wait a minute, or raise AUTH_RATE_LIMIT_MAX. If all your users are being limited at once, nginx is not forwarding X-Forwarded-For, so everyone looks like one visitor — see section 13, step 9.
pm2 logs bookly_backend --lines 50
Usually a missing .env in the folder named by cwd in prod.config.js, or wrong database credentials.
NEXT_PUBLIC_API_URL is missing the /api/v1 ending. It must be, for example, https://api.your-domain.com/api/v1. Fix it and run npm run build again.
Either NEXT_PUBLIC_IMAGE_URL wrongly ends in /uploads (the paths already contain it), or your CDN hostname is not allowed — add it to NEXT_PUBLIC_EXTRA_IMAGE_HOSTS.
Add the admin panel's address to CORS_ORIGINS in the backend .env, then restart the backend.
.env but nothing happenedNEXT_PUBLIC_* values are compiled into the browser bundle. Run npm run build and restart.
Yes. The app is a client for the Bookly API — the catalogue, accounts, purchases and reading progress all live there.
Yes. Run them on different ports (5008 and 4000 by default) and put nginx in front of both.
No. The schema relies on PostgreSQL-specific features such as jsonb for multilingual fields and native enum types.
English, Bangla, Arabic and Hindi out of the box, including right-to-left layout for Arabic. Set the active list in APP_LOCALES (backend) and NEXT_PUBLIC_APP_LOCALES (admin panel) — they must match. Adding another language is covered in section 9.
In the admin panel: Books → Add Book. Upload the EPUB or PDF and a cover, fill in the details for each language, set pricing, then publish. Draft books do not appear in the app.
No. It is off by default. The payment layer is provider-based, and Google Play / Apple in-app purchases are supported separately.
Cloudflare R2, Bunny.net, or the local uploads/ folder — whichever is configured first. See section 13, step 6.
Set PROTECT_BOOK_FILES=true. Links then carry an expiring signature and cannot be reused elsewhere. Your apps need no changes. Note that paid books are already withheld from readers who have not bought them.
Yes — both are configurable. See sections 10 and 11.
flutter doctor -v (app issues) or pm2 logs (backend issues), and we will get you moving. Contact details are in section 18.
If you need help, reach out using any of the channels below.
5. Social Sign In Config
To enable social authentication methods for your app, you need to configure the providers in your Firebase console.
You need to place your Apple Service ID and Redirect URL in the project configuration file located at
lib/core/config/app_config.dart.For detailed instructions on how to set up your Service ID and Redirect URL, please follow this guide: