StoryHub
A quiet place to write stories and read them.
A long-form writing platform with a social layer — following, likes, threaded comments, bookmarks and notifications — fully bilingual in Arabic and English with right-to-left layout, installable as a PWA. A Django REST Framework API and a React + TypeScript app, running on a Linux VPS I set up and operate.
The data model, all seven apps, every endpoint, authentication, Celery, caching — and the 85 tests.
Designed with an AI design tool and coded with AI coding tools — no other developers involved. The integration with the API — contract, auth flow, data shapes — is my work.
Server, Nginx, systemd, PostgreSQL, backups with a rehearsed restore, and the SEO layer.
Six things in StoryHub that show how I work — open any of them; where there is code, it is right there.
01 Authorization One rule decides who can read a story
Feeds, writer pages, direct links, likes, comments, bookmarks and the sitemap all ask the same queryset method, visible_to(). Adding followers-only stories meant changing one place, not seven.
class StoryQuerySet(models.QuerySet): """The one place that knows who may read a story.""" def visible_to(self, user, *, with_own_unpublished=False): published = Q(status=Story.StatusChoices.PUBLISHED) public = Q(visibility=Story.VisibilityChoices.PUBLIC) if user is None or not user.is_authenticated: return self.filter(published & public) follows_author = Exists( Follow.objects.filter(follower=user, following=OuterRef("author_id")) ) allowed = published & (public | Q(author=user) | Q(follows_author)) if with_own_unpublished: allowed |= Q(author=user) return self.filter(allowed)
02 ORM & performance Interaction state computed by the database
Liked, saved and following are Exists/OuterRef annotations — one query per page instead of one per item. Follower counts use a Subquery after a joined Count inflated the numbers.
03 Search & sharing A single-page app that Google and WhatsApp can read
Django serves the built shell with its <head> rewritten per route — title, Open Graph, canonical URL, JSON-LD — plus a sitemap that reuses the visibility rule, and an X-Robots-Tag middleware instead of blocking the API in robots.txt.
# Crawlers must be allowed to *fetch* the API; what they # must not do is list its JSON as pages. NOINDEX_PREFIXES = ("/api/", "/auth/", "/admin/") class NoIndexApiMiddleware: def __call__(self, request): response = self.get_response(request) if request.path.startswith(NOINDEX_PREFIXES): response["X-Robots-Tag"] = "noindex, nofollow" return response
04 Security HTML sanitised once, on write
Story bodies are cleaned against an allow-list (nh3) before they are stored, so no read path — API, admin, a future export — has to remember to escape anything.
05 Authentication Google sign-in verified on the server
ID tokens are checked with google-auth, a verified email is required, usernames are generated on collision, and Google-only accounts get a set password flow instead of change password.
06 Operations A deploy script that can’t break itself
deploy.sh runs as one bash function called on its last line — because git pull once replaced the running script midway and a step was silently skipped.
# A function is parsed whole before it runs, so the copy # in memory is the one that finishes — even if `git pull` # replaces this file midway. set -euo pipefail deploy() { $RUN_AS git -C "$APP" pull --ff-only $RUN_AS bash -c "cd '$APP/backend' && .venv/bin/python manage.py migrate" $RUN_AS bash -c "cd '$APP/frontend' && npm ci && npm run build" systemctl restart storyhub-web storyhub-worker } deploy "$@"
One server, one domain, every service accounted for.
Nginx serves the built frontend and static files and proxies the API to Gunicorn; PostgreSQL, Redis and a Celery worker run beside it as sandboxed systemd units. A nightly backup is mirrored off-site, and the restore was rehearsed before launch. CI runs the tests and the build on every push; the deploy itself is a one-command script over SSH — deliberately manual at this scale.
- Nginxserves the app, proxies the API
- Gunicornruns Django
- PostgreSQLthe database
- Rediscache and Celery broker
- Celeryemail off the request path
- systemdsandboxed units, dependency order
- Let’s EncryptTLS, HSTS, security headers
- Backblaze B2nightly off-site backups
- Resendemail with SPF, DKIM, DMARC
- UptimeRobotpolled every five minutes
- GitHub Actions85 tests + frontend build, every push
Designed with Google Stitch and rebuilt with Claude, then integrated with the API by me: a token-based light and dark theme, right-to-left layout handled as data, optimistic updates kept in sync across every list, a Tiptap editor with inline image upload, Arabic and English via i18next, and a service worker for installability.
Stack


