Each product in PostHog is a vertical slice: it contains its backend (Django app), frontend (React/TypeScript), and optionally shared code. This structure ensures product features are self-contained and can evolve independently.
The entire product folder (products/<product_name>/) is treated as a Turborepo package.
Backend and frontend are sub-parts of that package.
This is the (future) home for all PostHog products (RFC).
For the detailed architecture rationale (frozen dataclasses, facades, isolated testing), see architecture.md.
products/
__init__.py
<product_name>/ # Turborepo package boundary
__init__.py # allows imports like products.<product>.backend.*
manifest.tsx # describes the product's features
package.json # defines the product package in Turborepo
backend/ # Django app
__init__.py
apps.py
models.py
logic.py # business logic
migrations/
facade/ # cross-product Python interface
__init__.py
api.py # facade methods
contracts.py # frozen dataclasses (+ enums)
enums.py # optional: exported enums/shared types
presentation/ # DRF views/serializers
__init__.py
views.py
serializers.py
urls.py
tasks/ # Celery tasks
__init__.py
tasks.py
tests/
conftest.py
test_*.py
frontend/
components/
scenes/
hooks/
logics/
generated/ # OpenAPI-generated TypeScript typesUse bin/hogli product:bootstrap <name> to scaffold a new product with this structure.
-
Each
backend/folder is a real Django app. -
Register it in
INSTALLED_APPSviaAppConfig:# products/feature_flags/backend/apps.py from django.apps import AppConfig class FeatureFlagsConfig(AppConfig): name = "products.feature_flags.backend" label = "feature_flags" verbose_name = "Feature Flags"
-
✅ Always use the real Python path for imports:
from products.feature_flags.backend.models import FeatureFlag
-
✅ For relations, use string app labels:
class Experiment(models.Model): feature_flag = models.ForeignKey( "feature_flags.FeatureFlag", on_delete=models.CASCADE, )
-
❌ Do not import models from
posthog.modelsor create re-exports likeproducts.feature_flags.models.
This avoids circular imports and keeps migrations/app labels stable.
- Each
frontend/directory contains the frontend app for the product. - It lives under the same package as the backend.
- Backend and frontend tooling can be independent (
requirements.txtvs.package.json) but remain in the same Turborepo package. - Tests for frontend code live inside
frontend/tests/.
If backend and frontend need shared schemas, validators, or constants, put them in a shared/ directory under the product.
Keep shared code minimal to avoid tight coupling.
- Each high level product should have its own folder.
- Please keep the top level folders
under_scorecased, as dashes make it hard to import files in some languages (e.g. Python).
- Please keep the top level folders
- Each product has a few required files / folders:
manifest.tsx- describes the product's features. All manifest files are combined intofrontend/src/products.tsxandfrontend/src/products.jsonon build.package.json- describes the frontend dependencies. Ideally they should all bepeerDependenciesof whatever is infrontend/package.json__init__.py- allows imports likeproducts.<product>.backend.*(only if backend exists)backend/__init__.py- marks the backend directory as a Python package/Django app (only if backend exists).frontend/- React frontend code. We run oxfmt/eslint only on files in thefrontendfolder on commit.backend/- Python backend code. It's treated as a separate django app.
The easiest way is to use hogli:
bin/hogli product:bootstrap your_product_nameThis creates the full structure with apps.py, package.json, etc.
To check your product structure follows conventions:
bin/hogli product:lint your_product_nameThe lint command validates:
- Presence:
backend:testmust exist; isolated products must also havebackend:contract-check - Absence: products must NOT have
backend:contract-checkif they are not isolated or have legacy interface leaks (where core still imports internals) — turbo-discover uses this key to classify products as isolated, which causes the full Django test suite to be skipped when that product changes - Legacy leaks: products with TODO legacy leak blocks in
tach.tomlshow a⚠warning in the tach boundaries check - Script content (for
backend:test):- No
|| trueor|| exit 0— these swallow test failures in CI - No no-op scripts (e.g.,
echo 'No backend tests') whenbackend/contains actual test files - Pytest paths referenced in the command must exist on disk and contain discoverable tests
- No
Note
To migrate a product to full isolation (facade + contracts + selective testing), use the isolating-product-facade-contracts skill. See products/architecture.md for the target architecture.
- Create a new folder
products/your_product_name, keep it underscore-cased. - Create a
manifest.tsxfile- Describe the product's frontend
scenes,routes,urls, file system types, and project tree (navbar) items. - All manifest files are combined into a single
frontend/src/products.tsxfile on build. - NOTE: we don't copy imports into
products.tsx. If you add new icons, update the imports manually infrontend/src/products.tsx. It only needs to be done once. - NOTE: if you want to add a link to the old pre-project-tree navbar, do so manually in
frontend/src/layout/navigation-3000/navigationLogic.tsx
- Describe the product's frontend
- Create a
package.jsonfile:- Keep the package name as
@posthog/products-your-product-name. Include@posthog/products-in the name. - Update the global
frontend/package.json: add your new npm package underdependencies. - If your scenes are linked up with the right paths, things should just work.
- Each scene can either export a React component as its default export, or define a
export const scene: SceneExport = { logic, component }object to export both a logic and a component. This way the logic stays mounted when you move away from the page. This is useful if you don't want to reload everything each time the scene is loaded.
- Keep the package name as
- Create
__init__.pyandbackend/__init__.pyfiles if your product has python backend code.__init__.pyallows imports likeproducts.<name>.backend.*backend/__init__.pymarks the backend directory as a Python package / Django app.- Register the backend as a Django app with an
AppConfigthat setslabel = "<name>"(notproducts.<name>). - Modify
posthog/settings/web.pyand add your new product underPRODUCTS_APPS. - Modify
tach.tomland add a new block for your product. We usetachto track cross-dependencies between python apps. - Modify
posthog/api/__init__.pyand add your API routes as you normally would (e.g.import products.early_access_features.backend.api as early_access_feature) - NOTE: we will automate some of these steps in the future, but for now, please do them manually.
- Create or move your backend models under the product's
backend/folder. - Use direct imports from the product location (e.g.,
from products.experiments.backend.models import Experiment) - Use string-based foreign key references to avoid circular imports (e.g.,
models.ForeignKey("posthog.Team", on_delete=models.CASCADE)) - Create a
products/your_product_name/backend/migrationsfolder. - Run
python manage.py makemigrations your_product_name -n initial_migration - If this is a brand-new model, you're done.
- If you're moving a model from the old
posthog/models/folder, there are more things to do:- Make sure the model's
Metaclass hasdb_table = 'old_table_name'set along withmanaged = True. - Run
python manage.py makemigrations posthog -n remove_old_product_name - The generated migrations will want to
DROP TABLEyour old model, andCREATE TABLEthe new one. This is not what we want. - Instead, we want to run
migrations.SeparateDatabaseAndStatein both migrations. - Follow the example in
posthog/migrations/0548_migrate_early_access_features.pyandproducts/early_access_features/migrations/0001_initial_migration.py. - Move all operations into
state_operations = []and keep thedatabase_operations = []empty in both migrations. - Run and test this a few times before merging. Data loss is irreversible.
- Make sure the model's
Database isolation is part of the broader product isolation architecture (see architecture.md). Products communicate through facades and frozen dataclass contracts — never through shared ORM queries or cross-product joins. Separate databases enforce this at the infrastructure level: if your product can't reach another product's tables, you can't accidentally couple to them.
New products get their own Postgres database by default (hogli product:bootstrap adds a route automatically).
Opting out: Remove the product's entry from products/db_routing.yaml and everything falls back to default. This weakens isolation — a bad migration or traffic spike in your product can impact the entire app, and nothing prevents accidental cross-product ORM queries. Acceptable reasons to opt out: the product has no models, or it's in early prototyping and not yet following the facade pattern.
A route in products/db_routing.yaml declares which app label gets its own database:
routes:
- app_label: visual_review
database: visual_reviewThis automatically:
- Registers
visual_review_db_writerandvisual_review_db_readeras Django database aliases - Routes all reads/writes for the
visual_reviewapp throughProductDBRouter - Runs migrations via
bin/migrate(callsmigrate_product_databasesmanagement command) - Creates the database in local Docker via the Postgres init script
Locally (DEBUG=1), it auto-connects to posthog_visual_review on localhost. In prod, the infrastructure handles env vars and connections automatically. If the env var is absent, the route is silently skipped.
- Add a route in
products/db_routing.yaml(this repo) - Ask
#team-infrastructureto provision the database — they'll handle the cluster, credentials, and connection plumbing
Postgres doesn't support foreign keys across databases. Models on a product database must not use ForeignKey to models in the main database (Team, User, etc.). Use plain integer fields instead:
# Do this — plain integer, no FK constraint
team_id = models.BigIntegerField(db_index=True)
# Not this — can't reference a table in another database
team = models.ForeignKey("posthog.Team", on_delete=models.CASCADE)ForeignKeys between models within the same product database are fine.
This aligns with the facade pattern: if your product needs data from Team or User, fetch it through the facade using IDs — don't join to the table directly. Consequences:
- No
select_related/prefetch_relatedacross databases — use the facade or manual batch fetching - No
ON DELETE CASCADEfrom the main DB — handle cleanup in application code or via background tasks - No
transaction.atomic()spanning both databases — design for eventual consistency across boundaries
Products use Turborepo for selective testing. Only tests affected by your changes run.
# Run all product tests
pnpm turbo run backend:test
# Run specific product tests
pnpm turbo run backend:test --filter=@posthog/products-visual_review
# Dry-run to see what would execute
pnpm turbo run backend:test --dry-run=jsonSee architecture.md for how selective testing works.