Inkline is a small Medium-style writing app built for learning. It has a vanilla HTML/CSS/JS frontend, a Node HTTP backend, and Postgres persistence through Prisma.
- A responsive story feed with search, topic filters, and saved stories.
- Following authors and topics.
- Personalized "For you" and "Following" feeds.
- In-app notifications for follows, new stories, claps, and responses.
- Writer analytics for story views, reads, followers, and subscribers.
- Writer subscriptions and publication newsletter subscriptions.
- Publications with owners, editors, writers, submission review, and email newsletters.
- Dedicated article URLs like
/stories/:id/:slug. - Account creation, sign in, sign out, and cookie-based sessions.
- Author settings with profile and email editing.
- Story creation, editing, and deletion for the signed-in author.
- Draft saving before publishing.
- Paginated feed loading.
- A contenteditable rich text editor with bold, italic, headings, quotes, lists, and links.
- Cover image URLs plus local or Supabase Storage uploads.
- Claps, bookmarks, and reader responses.
- Response deletion by the response author or story author.
- Response hiding/showing by story authors and admins.
- Reporting and blocking controls with block-aware feeds and interactions.
- Admin moderation queues for reports, responses, stories, and production diagnostics.
- Postgres full-text search across titles, subtitles, authors, topics, and story bodies.
- Email verification and password reset flows using expiring single-use tokens plus an email delivery adapter.
- In-memory fixed-window rate limiting for auth, uploads, and responses.
- Postgres tables, Prisma schema, and Prisma migrations.
- API and browser-level tests for the main publishing flow.
- Admin diagnostics for failed email delivery and image cleanup.
Create a .env file from .env.example, then set DATABASE_URL.
For a local Postgres database:
createdb inkline_prisma_devDATABASE_URL="postgresql://YOUR_MAC_USER@localhost:5432/inkline_prisma_dev"
DIRECT_URL="postgresql://YOUR_MAC_USER@localhost:5432/inkline_prisma_dev"For Neon, use the pooled connection string for DATABASE_URL and the direct connection string for DIRECT_URL.
Apply the Prisma migration and seed starter stories:
npm run migrateIf you already have local data in data/db.json, import it into Postgres:
npm run db:import-jsonKeep Neon config in a separate local file so you can switch between local Postgres and Neon without editing the same .env over and over:
cp .env.neon.example .env.neonIn .env.neon, paste the pooled Neon connection string into DATABASE_URL and the direct Neon connection string into DIRECT_URL.
Then apply migrations to Neon:
npm run neon:migrateOptionally import your legacy data/db.json data into Neon:
npm run neon:import-jsonRun the app against Neon:
npm run start:neonUseful Neon commands:
npm run neon:status
NEON_ENV_FILE=.env.neon-preview npm run neon:migrateUploads use local disk by default and are served from /uploads. To move uploaded cover images to Supabase Storage, create a public bucket such as inkline-uploads, then set these server-side environment variables:
STORAGE_PROVIDER=supabase
SUPABASE_URL="https://YOUR_PROJECT_REF.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="your_server_only_service_role_key"
SUPABASE_STORAGE_BUCKET=inkline-uploads
SUPABASE_STORAGE_PATH_PREFIX=story-coversSUPABASE_SERVICE_ROLE_KEY must stay on the server and must not be exposed in browser code or committed to Git. SUPABASE_PUBLIC_URL is optional if you later front the bucket with a custom CDN/public base URL.
npm startThen open:
http://localhost:4173
Inkline deploys as one hosted Node web service connected to managed Postgres, image storage, and email delivery. The current production-friendly path is:
- Neon for
DATABASE_URLandDIRECT_URL. - Supabase Storage for uploaded cover images.
- Resend for email verification and password reset delivery.
- A Node host such as Render, Railway, or Fly.io.
Hosted Node settings:
Node version: 24.x
Build command: npm ci
Migration command: npm run db:deploy
Start command: npm start
Fallback start command: npm run start:prod
Health check path: /healthz
Use npm run db:deploy as a separate release, pre-deploy, or migration command when your host supports one. If your host only gives you a build command and a start command, use npm run start:prod for a small single-instance learning deployment; it applies Prisma migrations before starting the server.
Set these environment variables in the host's secret variable UI, not in committed files:
NODE_ENV=production
HOST=0.0.0.0
APP_URL=https://your-inkline-domain.com
DATABASE_URL="your pooled Neon connection string"
DIRECT_URL="your direct Neon connection string"
STORAGE_PROVIDER=supabase
SUPABASE_URL="https://YOUR_PROJECT_REF.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="your_server_only_service_role_key"
SUPABASE_STORAGE_BUCKET=inkline-uploads
SUPABASE_STORAGE_PATH_PREFIX=story-covers
EMAIL_PROVIDER=resend
RESEND_API_KEY=your_resend_key
EMAIL_FROM="Inkline <hello@yourdomain.com>"
ADMIN_EMAILS=you@yourdomain.comMost hosts inject PORT automatically, so do not hard-code it unless the host asks you to. Keep HOST=0.0.0.0 in production so the platform proxy can reach the Node process. Use Supabase Storage in production because local uploaded files are not durable on many hosted runtimes.
Platform notes:
- Render: create a Web Service from the GitHub repo. Render's Node guide uses build and start commands, and Render's deploy docs describe pre-deploy commands for tasks like database migrations. Use
npm ci,npm run db:deploy, andnpm startwhen pre-deploy is available; otherwise usenpm run start:prod. - Railway: add variables from the service's Variables tab or RAW editor, then deploy from the GitHub repo. Use the same hosted Node settings above.
- Fly.io: make sure the app listens on
0.0.0.0and that Fly's internal port matches the app'sPORT. Fly's troubleshooting docs call this out as a common cause of unreachable deployments.
Production smoke test:
curl https://your-inkline-domain.com/healthzThen open the app in the browser and check:
- Register a new account and verify the email link.
- Publish a story with an uploaded cover image.
- Reload the dedicated story URL.
- Add a response, clap, and bookmark.
- Request a password reset and confirm the reset link uses
APP_URL. - Confirm the host logs do not show migration, email, storage, or Prisma errors.
- Open Admin tools and confirm Diagnostics is empty after the smoke test.
The API test suite creates a temporary local Postgres database by default, applies Prisma migrations, starts the server on a random port, runs the main API flows, and drops the database afterward. The Playwright suite starts the app in a browser and clicks through sign up, writing, publishing, reading, responding, bookmarking, clapping, and editing.
Install the Playwright browser once before running browser tests locally:
npx playwright install chromiumIf that download is slow and you already have Google Chrome installed, run the browser suite with:
PLAYWRIGHT_USE_SYSTEM_CHROME=1 npm run test:e2eRun all tests:
npm testRun only one layer:
npm run test:api
npm run test:e2eUse TEST_DATABASE_URL if you want to point the tests at your own disposable database instead:
TEST_DATABASE_URL="postgresql://YOUR_MAC_USER@localhost:5432/inkline_test" npm testGitHub Actions installs Chromium, then runs npm run check and npm test against Postgres on every push and pull request to main.
Useful database commands:
npm run db:generate
npm run db:migrate
npm run db:deploy
npm run db:seed
npm run db:studioindex.htmlholds the page structure and dialogs.styles.csscontrols layout, responsive behavior, editor styling, and dialog styling.app.jsholds frontend state, rendering, routing, and API calls.server.jsholds the HTTP server, API routes, sanitization, direct Prisma story/auth/comment/upload/profile/admin flows, image storage adapters, and the JSON import path for old local data.prisma/schema.prismadefines the Postgres tables and relationships.prisma/migrations/contains SQL migrations generated from the Prisma schema.data/db.jsonis now legacy local data that can be imported withnpm run db:import-json.uploads/is created automatically when local image storage is enabled.- Admin diagnostics are stored in the
SystemEventtable and shown in Admin tools.
GET /api/sessionGET /healthzPOST /api/auth/registerPOST /api/auth/loginPOST /api/auth/logoutPOST /api/auth/request-verificationPOST /api/auth/verify-emailPOST /api/auth/request-resetPOST /api/auth/reset-passwordGET /api/followsPOST /api/follows/authorsPOST /api/follows/topicsGET /api/notificationsPOST /api/notifications/readPUT /api/meGET /api/me/draftsGET /api/me/analyticsGET /api/writers/:authorId/subscriptionPOST /api/writers/:authorId/subscriptionGET /api/publicationsPOST /api/publicationsGET /api/publications/:idPOST /api/publications/:id/membersPOST /api/publications/:id/submissionsPOST /api/publications/:id/submissions/:submissionIdPOST /api/publications/:id/subscribePOST /api/publications/:id/newslettersGET /api/blocksPOST /api/blocksPOST /api/reportsPOST /api/uploadsGET /api/admin/moderationPOST /api/admin/reports/:reportIdPOST /api/admin/responses/:responseId/moderateDELETE /api/admin/responses/:responseIdDELETE /api/admin/stories/:idGET /api/storiesGET /api/stories/:idPOST /api/storiesPUT /api/stories/:idDELETE /api/stories/:idPOST /api/stories/:id/clapPOST /api/stories/:id/bookmarkPOST /api/stories/:id/viewPOST /api/stories/:id/readPOST /api/stories/:id/responsesDELETE /api/stories/:id/responses/:responseIdPOST /api/stories/:id/responses/:responseId/moderate
Verification and reset links are generated with production-style token rules: random tokens, hashed storage, expiry times, and single use after success. By default, the app uses the development email adapter and shows generated links in the UI.
For production delivery, Inkline can send through Resend's Email API. Resend expects a verified sending domain and sends messages through POST /emails; see the official Send Email and Managing Domains docs.
Set these server-side environment variables before starting the server:
APP_URL=https://your-inkline-domain.com
EMAIL_PROVIDER=resend
RESEND_API_KEY=your_resend_key
EMAIL_FROM="Inkline <hello@yourdomain.com>"APP_URL is used inside verification and password reset links. Set it to the real HTTPS origin users will open in the browser. RESEND_API_KEY must stay on the server and must not be exposed in browser code or committed to Git. If EMAIL_PROVIDER=resend is configured incorrectly, Inkline records an admin diagnostic and does not show a development link to users.
Production email setup checklist:
- Add your sending domain in Resend and wait until the domain status is verified.
- Use a sender address on that verified domain, such as
Inkline <hello@yourdomain.com>. - Store
RESEND_API_KEY,EMAIL_PROVIDER=resend,EMAIL_FROM, andAPP_URLin the host's secret environment settings. - Register a test account and confirm the verification email arrives in the inbox.
- Click the verification link and confirm the app marks the account as verified.
- Request a password reset and confirm the reset link opens the app on the production domain.
- Confirm Resend logs show the messages as accepted or delivered.
- Confirm no production secrets are present in
.env,.env.neon, shell history, screenshots, or committed files.
Set ADMIN_EMAILS to a comma-separated list to make known accounts admins. The first registered user is also made an admin locally so you can reach the moderation tools.
The server includes in-memory fixed-window limits for noisy write paths:
AUTH_RATE_LIMIT_MAX=30
AUTH_RATE_LIMIT_WINDOW_MS=900000
UPLOAD_RATE_LIMIT_MAX=20
UPLOAD_RATE_LIMIT_WINDOW_MS=3600000
RESPONSE_RATE_LIMIT_MAX=12
RESPONSE_RATE_LIMIT_WINDOW_MS=300000Auth limits are keyed by client IP. Upload and response limits are keyed by signed-in user. This is enough for a single Node server; for multi-instance production, move counters to Redis or another shared store.
- Trace
boot()inapp.jsto see how the page loads session data and stories. - Follow
submitAuth()intoserver.jsto learn how sessions are created. - Read
prisma/schema.prismato see how users, stories, publications, subscriptions, analytics, reports, blocks, and notifications map to tables. - Read
handleStoryIndexPrisma(),handleStoryDetailPrisma(), andhandleMyDraftsPrisma()inserver.jsto see direct Prisma story reads. - Read
handleRegisterPrisma(),handleLoginPrisma(), andhandleResetPasswordPrisma()to see direct Prisma auth/session writes. - Follow
submitStory()intohandleCreateStoryPrisma()andhandleUpdateStoryPrisma()to see frontend data become persisted Postgres data. - Read
handleCreateResponsePrisma(),handleDeleteResponsePrisma(), andhandleModerateResponsePrisma()to see direct Prisma comment writes. - Read
handleUploadPrisma(),handleUpdateMePrisma(), andhandleAdminModerationPrisma()to see direct Prisma file metadata, profile, diagnostics, and admin flows. - Read
importJsonDatabase()andwriteDb()inserver.jsto understand how olddata/db.jsonrecords are imported into Postgres tables. - Read
getFollowStatePrisma(),recommendationScore(), andnotifyStoryFollowersPrisma()to see how follows power feeds and notifications. - Read
findPublishedStoryIdsBySearch()and the full-text search migration to see how Postgres ranks matching stories. - Read
applyRateLimit()to see how fixed-window rate limiting protects auth, uploads, and responses. - Read
saveLocalImageUpload()andsaveSupabaseImageUpload()to see how upload storage is swapped by environment. - Read
test/api.test.jsto see how publishing, analytics, publications, newsletters, reports, blocking, and moderation are tested through HTTP. - Read
e2e/writing.spec.jsto see how Playwright tests the same flow through the browser UI. - Read
validateStoryInput()to see how drafts and published stories use different validation rules. - Use
npm run db:studioto inspect the database visually while you create stories in the app.
- Add a Redis-backed rate limiter for multi-instance production deploys.
- Add a retry or resolve workflow for stored production diagnostics.
- Add time-series analytics and referrer breakdowns instead of lifetime counters only.
- Add publication invitations, member removal, and scheduled newsletter delivery.
- Add moderator notes, report appeals, and account-level suspension tools.