products/README.md
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 types
Use 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_APPS via AppConfig:
# 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.models or create re-exports like products.feature_flags.models.
This avoids circular imports and keeps migrations/app labels stable.
frontend/ directory contains the frontend app for the product.requirements.txt vs. package.json) but remain in the same Turborepo package.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.
under_score cased, as dashes make it hard to import files in some languages (e.g. Python).manifest.tsx - describes the product's features. All manifest files are combined into frontend/src/products.tsx and frontend/src/products.json on build.package.json - describes the frontend dependencies. Ideally they should all be peerDependencies of whatever is in frontend/package.json__init__.py - allows imports like products.<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 the frontend folder 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_name
This creates the full structure with apps.py, package.json, etc.
To check your product structure follows conventions:
bin/hogli product:lint your_product_name
products/your_product_name, keep it underscore-cased.manifest.tsx file
scenes, routes, urls, file system types, and project tree (navbar) items.frontend/src/products.tsx file on build.products.tsx. If you add new icons, update the imports manually in frontend/src/products.tsx. It only needs to be done once.frontend/src/layout/navigation-3000/navigationLogic.tsxpackage.json file:
@posthog/products-your-product-name. Include @posthog/products- in the name.frontend/package.json: add your new npm package under dependencies.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.__init__.py and backend/__init__.py files if your product has python backend code.
__init__.py allows imports like products.<name>.backend.*backend/__init__.py marks the backend directory as a Python package / Django app.AppConfig that sets label = "<name>" (not products.<name>).posthog/settings/web.py and add your new product under PRODUCTS_APPS.tach.toml and add a new block for your product. We use tach to track cross-dependencies between python apps.posthog/api/__init__.py and add your API routes as you normally would (e.g. import products.early_access_features.backend.api as early_access_feature)backend/ folder.from products.experiments.backend.models import Experiment)models.ForeignKey("posthog.Team", on_delete=models.CASCADE))products/your_product_name/backend/migrations folder.python manage.py makemigrations your_product_name -n initial_migrationposthog/models/ folder, there are more things to do:
Meta class has db_table = 'old_table_name' set along with managed = True.python manage.py makemigrations posthog -n remove_old_product_nameDROP TABLE your old model, and CREATE TABLE the new one. This is not what we want.migrations.SeparateDatabaseAndState in both migrations.posthog/migrations/0548_migrate_early_access_features.py and products/early_access_features/migrations/0001_initial_migration.py.state_operations = [] and keep the database_operations = [] empty in both migrations.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=json
See architecture.md for how selective testing works.