@voltro/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/LICENSE +57 -0
- package/README.md +26 -0
- package/SECURITY.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20236 -0
- package/bin/voltro.mjs +43 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +9 -0
- package/dist/commands-CfPH2Wf4.js +18061 -0
- package/dist/frameworkInspectState-CX2250XB.js +86 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +5 -0
- package/dist/inspectState.d.ts +116 -0
- package/dist/inspectState.js +2 -0
- package/dist/startup.d.ts +26 -0
- package/dist/startup.js +2 -0
- package/dist/startupRunner-CRhuUl91.js +71 -0
- package/package.json +88 -0
- package/templates/AGENTS.core.md +258 -0
- package/templates/AGENTS.md +351 -0
- package/templates/agent-docs/_index.md +93 -0
- package/templates/agent-docs/_manifest.json +655 -0
- package/templates/agent-docs/ai.md +1845 -0
- package/templates/agent-docs/authentication.md +1788 -0
- package/templates/agent-docs/caching.md +624 -0
- package/templates/agent-docs/cli.md +1650 -0
- package/templates/agent-docs/configuration.md +295 -0
- package/templates/agent-docs/data.md +2172 -0
- package/templates/agent-docs/database/advancedqueries.md +1583 -0
- package/templates/agent-docs/database/columntypes.md +1200 -0
- package/templates/agent-docs/database/hosting.md +881 -0
- package/templates/agent-docs/database/migrations.md +2938 -0
- package/templates/agent-docs/database/misc.md +270 -0
- package/templates/agent-docs/database/overview.md +108 -0
- package/templates/agent-docs/database/querying.md +1622 -0
- package/templates/agent-docs/database/scaling.md +331 -0
- package/templates/agent-docs/database/schema.md +1458 -0
- package/templates/agent-docs/database/seedsdialects.md +1235 -0
- package/templates/agent-docs/database/transactions.md +285 -0
- package/templates/agent-docs/deployment.md +999 -0
- package/templates/agent-docs/internationalization.md +359 -0
- package/templates/agent-docs/introduction.md +438 -0
- package/templates/agent-docs/multi-tenancy.md +610 -0
- package/templates/agent-docs/observability.md +350 -0
- package/templates/agent-docs/plugins.md +1273 -0
- package/templates/agent-docs/reference.md +978 -0
- package/templates/agent-docs/routing.md +1553 -0
- package/templates/agent-docs/scheduling.md +666 -0
- package/templates/agent-docs/schema-driven-ui.md +607 -0
- package/templates/agent-docs/security.md +42 -0
- package/templates/agent-docs/templates/apibackends.md +3214 -0
- package/templates/agent-docs/templates/appshells.md +2062 -0
- package/templates/agent-docs/templates/custom.md +128 -0
- package/templates/agent-docs/templates/overview.md +122 -0
- package/templates/agent-docs/templates/serverless.md +315 -0
- package/templates/agent-docs/testing.md +376 -0
- package/templates/agent-docs/workflows.md +1351 -0
- package/templates/apps/api-ai/README.md +105 -0
- package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
- package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
- package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
- package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
- package/templates/apps/api-ai/app.config.ts +57 -0
- package/templates/apps/api-ai/database/schema.ts +67 -0
- package/templates/apps/api-ai/package.json +27 -0
- package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
- package/templates/apps/api-ai/template.json +6 -0
- package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
- package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
- package/templates/apps/api-ai/tsconfig.json +5 -0
- package/templates/apps/api-auth/.env +17 -0
- package/templates/apps/api-auth/README.md +109 -0
- package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
- package/templates/apps/api-auth/actions/me.action.ts +19 -0
- package/templates/apps/api-auth/app.config.ts +59 -0
- package/templates/apps/api-auth/database/schema.ts +26 -0
- package/templates/apps/api-auth/package.json +28 -0
- package/templates/apps/api-auth/template.json +6 -0
- package/templates/apps/api-auth/tests/me.test.ts +37 -0
- package/templates/apps/api-auth/tsconfig.json +5 -0
- package/templates/apps/api-backend/README.md +25 -0
- package/templates/apps/api-backend/app.config.ts +39 -0
- package/templates/apps/api-backend/database/schema.ts +57 -0
- package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend/package.json +28 -0
- package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend/template.json +6 -0
- package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend/tsconfig.json +5 -0
- package/templates/apps/api-backend-deactivation/README.md +49 -0
- package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
- package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
- package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
- package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
- package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
- package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
- package/templates/apps/api-backend-deactivation/package.json +28 -0
- package/templates/apps/api-backend-deactivation/template.json +6 -0
- package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
- package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
- package/templates/apps/api-backend-mail/README.md +39 -0
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
- package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
- package/templates/apps/api-backend-mail/app.config.ts +34 -0
- package/templates/apps/api-backend-mail/database/schema.ts +57 -0
- package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-mail/package.json +30 -0
- package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-mail/template.json +6 -0
- package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-mail/tsconfig.json +5 -0
- package/templates/apps/api-backend-mariadb/.env.example +35 -0
- package/templates/apps/api-backend-mariadb/README.md +31 -0
- package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
- package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-mariadb/package.json +30 -0
- package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-mariadb/template.json +6 -0
- package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/README.md +86 -0
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
- package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
- package/templates/apps/api-backend-storage/app.config.ts +38 -0
- package/templates/apps/api-backend-storage/database/schema.ts +57 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-storage/package.json +27 -0
- package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-storage/template.json +6 -0
- package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-storage/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/.env +17 -0
- package/templates/apps/api-data-advanced/README.md +88 -0
- package/templates/apps/api-data-advanced/app.config.ts +56 -0
- package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
- package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
- package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
- package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
- package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
- package/templates/apps/api-data-advanced/database/index.ts +23 -0
- package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
- package/templates/apps/api-data-advanced/package.json +28 -0
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
- package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
- package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
- package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
- package/templates/apps/api-data-advanced/template.json +6 -0
- package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
- package/templates/apps/api-data-advanced/tsconfig.json +5 -0
- package/templates/apps/api-durable/README.md +81 -0
- package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
- package/templates/apps/api-durable/app.config.ts +41 -0
- package/templates/apps/api-durable/database/schema.ts +65 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
- package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
- package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
- package/templates/apps/api-durable/package.json +28 -0
- package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
- package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
- package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
- package/templates/apps/api-durable/template.json +6 -0
- package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
- package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
- package/templates/apps/api-durable/tsconfig.json +5 -0
- package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
- package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
- package/templates/apps/api-feature-flags/README.md +63 -0
- package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
- package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
- package/templates/apps/api-feature-flags/app.config.ts +41 -0
- package/templates/apps/api-feature-flags/database/schema.ts +37 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
- package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
- package/templates/apps/api-feature-flags/package.json +29 -0
- package/templates/apps/api-feature-flags/template.json +6 -0
- package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
- package/templates/apps/api-feature-flags/tsconfig.json +5 -0
- package/templates/apps/api-governance/.env +4 -0
- package/templates/apps/api-governance/README.md +61 -0
- package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
- package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
- package/templates/apps/api-governance/app.config.ts +35 -0
- package/templates/apps/api-governance/database/schema.ts +41 -0
- package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
- package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
- package/templates/apps/api-governance/package.json +29 -0
- package/templates/apps/api-governance/template.json +6 -0
- package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
- package/templates/apps/api-governance/tsconfig.json +5 -0
- package/templates/apps/api-kv/README.md +100 -0
- package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
- package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
- package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
- package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
- package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
- package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
- package/templates/apps/api-kv/app.config.ts +48 -0
- package/templates/apps/api-kv/database/schema.ts +62 -0
- package/templates/apps/api-kv/package.json +28 -0
- package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
- package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
- package/templates/apps/api-kv/template.json +6 -0
- package/templates/apps/api-kv/tests/sync.test.ts +103 -0
- package/templates/apps/api-kv/tsconfig.json +5 -0
- package/templates/apps/api-moderation/README.md +46 -0
- package/templates/apps/api-moderation/app.config.ts +33 -0
- package/templates/apps/api-moderation/database/schema.ts +45 -0
- package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
- package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
- package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
- package/templates/apps/api-moderation/package.json +29 -0
- package/templates/apps/api-moderation/template.json +6 -0
- package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
- package/templates/apps/api-moderation/tsconfig.json +5 -0
- package/templates/apps/api-observability/README.md +106 -0
- package/templates/apps/api-observability/app.config.ts +32 -0
- package/templates/apps/api-observability/database/schema.ts +38 -0
- package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
- package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
- package/templates/apps/api-observability/package.json +29 -0
- package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
- package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
- package/templates/apps/api-observability/template.json +6 -0
- package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
- package/templates/apps/api-observability/tsconfig.json +5 -0
- package/templates/apps/api-ratelimit/README.md +49 -0
- package/templates/apps/api-ratelimit/app.config.ts +42 -0
- package/templates/apps/api-ratelimit/database/schema.ts +37 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
- package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
- package/templates/apps/api-ratelimit/package.json +29 -0
- package/templates/apps/api-ratelimit/template.json +6 -0
- package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
- package/templates/apps/api-ratelimit/tsconfig.json +5 -0
- package/templates/apps/api-rbac/README.md +59 -0
- package/templates/apps/api-rbac/app.config.ts +48 -0
- package/templates/apps/api-rbac/database/schema.ts +37 -0
- package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
- package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
- package/templates/apps/api-rbac/package.json +29 -0
- package/templates/apps/api-rbac/template.json +6 -0
- package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
- package/templates/apps/api-rbac/tsconfig.json +5 -0
- package/templates/apps/api-rest/README.md +85 -0
- package/templates/apps/api-rest/app.config.ts +70 -0
- package/templates/apps/api-rest/database/schema.ts +52 -0
- package/templates/apps/api-rest/lib/product.ts +30 -0
- package/templates/apps/api-rest/package.json +27 -0
- package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
- package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
- package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
- package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
- package/templates/apps/api-rest/template.json +6 -0
- package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
- package/templates/apps/api-rest/tsconfig.json +5 -0
- package/templates/apps/api-saas/README.md +106 -0
- package/templates/apps/api-saas/app.config.ts +51 -0
- package/templates/apps/api-saas/database/schema.ts +50 -0
- package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
- package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas/package.json +31 -0
- package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
- package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas/template.json +6 -0
- package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
- package/templates/apps/api-saas/tsconfig.json +5 -0
- package/templates/apps/api-search/README.md +68 -0
- package/templates/apps/api-search/app.config.ts +31 -0
- package/templates/apps/api-search/database/schema.ts +47 -0
- package/templates/apps/api-search/lib/search.ts +27 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
- package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
- package/templates/apps/api-search/package.json +29 -0
- package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
- package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
- package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
- package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
- package/templates/apps/api-search/template.json +6 -0
- package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
- package/templates/apps/api-search/tsconfig.json +5 -0
- package/templates/apps/api-versioning/README.md +51 -0
- package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
- package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
- package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
- package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
- package/templates/apps/api-versioning/app.config.ts +24 -0
- package/templates/apps/api-versioning/database/schema.ts +38 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
- package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
- package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
- package/templates/apps/api-versioning/package.json +29 -0
- package/templates/apps/api-versioning/template.json +6 -0
- package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
- package/templates/apps/api-versioning/tsconfig.json +5 -0
- package/templates/apps/api-webhooks/.env +6 -0
- package/templates/apps/api-webhooks/README.md +106 -0
- package/templates/apps/api-webhooks/app.config.ts +16 -0
- package/templates/apps/api-webhooks/database/schema.ts +49 -0
- package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
- package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
- package/templates/apps/api-webhooks/package.json +28 -0
- package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
- package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
- package/templates/apps/api-webhooks/template.json +6 -0
- package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
- package/templates/apps/api-webhooks/tsconfig.json +5 -0
- package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
- package/templates/apps/changelog/README.md +77 -0
- package/templates/apps/changelog/app.config.ts +27 -0
- package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
- package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
- package/templates/apps/changelog/package.json +30 -0
- package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
- package/templates/apps/changelog/src/globals.css +66 -0
- package/templates/apps/changelog/src/globals.d.ts +16 -0
- package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
- package/templates/apps/changelog/src/lib/locale.ts +55 -0
- package/templates/apps/changelog/src/lib/releases.ts +21 -0
- package/templates/apps/changelog/src/locales/de.ts +22 -0
- package/templates/apps/changelog/src/locales/en.ts +30 -0
- package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
- package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
- package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
- package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
- package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
- package/templates/apps/changelog/src/pages/index.tsx +55 -0
- package/templates/apps/changelog/src/pages/layout.tsx +84 -0
- package/templates/apps/changelog/template.json +6 -0
- package/templates/apps/changelog/tsconfig.json +11 -0
- package/templates/apps/edge-functions/README.md +55 -0
- package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
- package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
- package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
- package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
- package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
- package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
- package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
- package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
- package/templates/apps/edge-functions/package.json +21 -0
- package/templates/apps/edge-functions/template.json +6 -0
- package/templates/apps/edge-functions/tsconfig.json +10 -0
- package/templates/apps/frontend-admin/README.md +40 -0
- package/templates/apps/frontend-admin/app.config.ts +37 -0
- package/templates/apps/frontend-admin/package.json +30 -0
- package/templates/apps/frontend-admin/src/config.ts +8 -0
- package/templates/apps/frontend-admin/src/globals.css +76 -0
- package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
- package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
- package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
- package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
- package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
- package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
- package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
- package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
- package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
- package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
- package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
- package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
- package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
- package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
- package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
- package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
- package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
- package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
- package/templates/apps/frontend-admin/template.json +6 -0
- package/templates/apps/frontend-admin/tsconfig.json +11 -0
- package/templates/apps/frontend-app/README.md +98 -0
- package/templates/apps/frontend-app/app.config.ts +43 -0
- package/templates/apps/frontend-app/package.json +30 -0
- package/templates/apps/frontend-app/src/locales/de.ts +36 -0
- package/templates/apps/frontend-app/src/locales/en.ts +43 -0
- package/templates/apps/frontend-app/src/locales/index.ts +15 -0
- package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
- package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
- package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
- package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
- package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
- package/templates/apps/frontend-app/template.json +6 -0
- package/templates/apps/frontend-app/tsconfig.json +11 -0
- package/templates/apps/frontend-blank/README.md +18 -0
- package/templates/apps/frontend-blank/app.config.ts +29 -0
- package/templates/apps/frontend-blank/package.json +29 -0
- package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
- package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
- package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
- package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
- package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
- package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
- package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
- package/templates/apps/frontend-blank/template.json +6 -0
- package/templates/apps/frontend-blank/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/README.md +65 -0
- package/templates/apps/frontend-contact/app.config.ts +25 -0
- package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
- package/templates/apps/frontend-contact/package.json +33 -0
- package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
- package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
- package/templates/apps/frontend-contact/src/config.ts +12 -0
- package/templates/apps/frontend-contact/src/globals.css +84 -0
- package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
- package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
- package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
- package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
- package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
- package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
- package/templates/apps/frontend-contact/template.json +6 -0
- package/templates/apps/frontend-contact/tsconfig.json +11 -0
- package/templates/apps/frontend-dashboard/README.md +54 -0
- package/templates/apps/frontend-dashboard/app.config.ts +39 -0
- package/templates/apps/frontend-dashboard/package.json +29 -0
- package/templates/apps/frontend-dashboard/src/config.ts +8 -0
- package/templates/apps/frontend-dashboard/src/globals.css +72 -0
- package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
- package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
- package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
- package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
- package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
- package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
- package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
- package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
- package/templates/apps/frontend-dashboard/template.json +6 -0
- package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
- package/templates/apps/frontend-docs/README.md +19 -0
- package/templates/apps/frontend-docs/app.config.ts +25 -0
- package/templates/apps/frontend-docs/package.json +29 -0
- package/templates/apps/frontend-docs/src/globals.css +32 -0
- package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
- package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
- package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
- package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
- package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
- package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
- package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
- package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
- package/templates/apps/frontend-docs/template.json +6 -0
- package/templates/apps/frontend-docs/tsconfig.json +11 -0
- package/templates/apps/frontend-i18n/README.md +61 -0
- package/templates/apps/frontend-i18n/app.config.ts +33 -0
- package/templates/apps/frontend-i18n/package.json +28 -0
- package/templates/apps/frontend-i18n/src/globals.css +46 -0
- package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
- package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
- package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
- package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
- package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
- package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
- package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
- package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
- package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
- package/templates/apps/frontend-i18n/template.json +6 -0
- package/templates/apps/frontend-i18n/tsconfig.json +11 -0
- package/templates/apps/frontend-landing/README.md +17 -0
- package/templates/apps/frontend-landing/app.config.ts +25 -0
- package/templates/apps/frontend-landing/package.json +29 -0
- package/templates/apps/frontend-landing/src/globals.css +23 -0
- package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
- package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
- package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
- package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
- package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
- package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
- package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
- package/templates/apps/frontend-landing/template.json +6 -0
- package/templates/apps/frontend-landing/tsconfig.json +11 -0
- package/templates/apps/frontend-spa/README.md +45 -0
- package/templates/apps/frontend-spa/app.config.ts +27 -0
- package/templates/apps/frontend-spa/package.json +29 -0
- package/templates/apps/frontend-spa/src/globals.css +84 -0
- package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
- package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
- package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
- package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
- package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
- package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-spa/template.json +6 -0
- package/templates/apps/frontend-spa/tsconfig.json +11 -0
- package/templates/apps/frontend-ssr/README.md +68 -0
- package/templates/apps/frontend-ssr/app.config.ts +32 -0
- package/templates/apps/frontend-ssr/package.json +29 -0
- package/templates/apps/frontend-ssr/src/globals.css +67 -0
- package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
- package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
- package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
- package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
- package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
- package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
- package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
- package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
- package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
- package/templates/apps/frontend-ssr/template.json +6 -0
- package/templates/apps/frontend-ssr/tsconfig.json +11 -0
- package/templates/apps/frontend-ssr-api/README.md +50 -0
- package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
- package/templates/apps/frontend-ssr-api/package.json +30 -0
- package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
- package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
- package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
- package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
- package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
- package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
- package/templates/apps/frontend-ssr-api/template.json +6 -0
- package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
- package/templates/apps/frontend-static-blog/README.md +49 -0
- package/templates/apps/frontend-static-blog/app.config.ts +34 -0
- package/templates/apps/frontend-static-blog/package.json +28 -0
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
- package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
- package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
- package/templates/apps/frontend-static-blog/src/globals.css +75 -0
- package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
- package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
- package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
- package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
- package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
- package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
- package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
- package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
- package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
- package/templates/apps/frontend-static-blog/template.json +6 -0
- package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
- package/templates/baselines/bare/.env.example +37 -0
- package/templates/baselines/bare/README.md +47 -0
- package/templates/baselines/bare/baseline.json +31 -0
- package/templates/baselines/bare/deploy/README.md +43 -0
- package/templates/baselines/bare/deploy/voltro.service.example +36 -0
- package/templates/baselines/compose/.env.example +46 -0
- package/templates/baselines/compose/README.md +69 -0
- package/templates/baselines/compose/baseline.json +51 -0
- package/templates/baselines/compose/docker/.dockerignore +38 -0
- package/templates/baselines/compose/docker/api.Dockerfile +57 -0
- package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
- package/templates/baselines/compose/docker/web.Dockerfile +59 -0
- package/templates/baselines/compose/docker-compose.dev.yml +89 -0
- package/templates/baselines/compose/docker-compose.prod.yml +87 -0
- package/templates/baselines/compose/docker-compose.yml +41 -0
- package/templates/baselines/compose-mariadb/.env.example +57 -0
- package/templates/baselines/compose-mariadb/README.md +78 -0
- package/templates/baselines/compose-mariadb/baseline.json +51 -0
- package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
- package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
- package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
- package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
- package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
- package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
- package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
- package/templates/baselines/helm/.env.example +39 -0
- package/templates/baselines/helm/README.md +98 -0
- package/templates/baselines/helm/baseline.json +53 -0
- package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
- package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
- package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
- package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
- package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
- package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
- package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
- package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
- package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
- package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
- package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
- package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
- package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
- package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
- package/templates/baselines/helm/deploy/README.md +94 -0
- package/templates/patches/@effect__cluster@0.59.0.patch +262 -0
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
# Routing
|
|
2
|
+
|
|
3
|
+
> Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/routing/overview.md -->
|
|
10
|
+
## Overview
|
|
11
|
+
|
|
12
|
+
_Voltro's file-based router — pages, layouts, render modes, loaders, navigation, islands. The web side of the framework._
|
|
13
|
+
|
|
14
|
+
The web side of a Voltro app uses **file-based routing**: drop a TSX file under `src/pages/`, the CLI discovers it on every boot + save, and the file becomes a route. No router config, no manual `<Route>` declarations, no codegen step.
|
|
15
|
+
|
|
16
|
+
This section covers everything about how URLs map to React + how Voltro decides when to render, what to ship to the browser, and how to navigate between pages.
|
|
17
|
+
|
|
18
|
+
## The shape of it
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
src/pages/
|
|
22
|
+
├── layout.tsx # outer shell (wraps every page)
|
|
23
|
+
├── error.tsx # error boundary
|
|
24
|
+
├── loading.tsx # pending UI
|
|
25
|
+
├── not-found.tsx # 404 fallback
|
|
26
|
+
├── index.tsx # /
|
|
27
|
+
├── about.tsx # /about
|
|
28
|
+
├── (marketing)/ # route group — no URL segment
|
|
29
|
+
│ ├── layout.tsx # marketing-scoped layout
|
|
30
|
+
│ └── pricing.tsx # /pricing
|
|
31
|
+
├── users/
|
|
32
|
+
│ ├── layout.tsx # users-scoped layout
|
|
33
|
+
│ ├── error.tsx # users-scoped error boundary
|
|
34
|
+
│ ├── [id].tsx # /users/:id
|
|
35
|
+
│ └── index.tsx # /users
|
|
36
|
+
└── docs/
|
|
37
|
+
└── [...slug].tsx # /docs/<anything> (catch-all)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
That's the whole router. No `<Route>`, no `<Switch>`, no `useRoutes`.
|
|
41
|
+
|
|
42
|
+
## What's in this section
|
|
43
|
+
|
|
44
|
+
- [Pages & dynamic segments](/docs/routing/pages) — file → URL mapping, `[id]`, `[...slug]`, query params
|
|
45
|
+
- [Layouts & route groups](/docs/routing/layouts) — `layout.tsx`, error boundaries, `(group)/` directories
|
|
46
|
+
- [Render modes](/docs/routing/render-modes) — `static` vs `ssr` vs `isr`, when to use each
|
|
47
|
+
- [Loaders & meta](/docs/routing/loaders-and-meta) — server-side data fetch + `<head>` injection
|
|
48
|
+
- [Navigation](/docs/routing/navigation) — `Link`, `useNavigate`, prefetch on hover
|
|
49
|
+
- [Islands](/docs/routing/islands) — hydrating only the interactive bits, JS-free pages
|
|
50
|
+
|
|
51
|
+
## Architecture in one paragraph
|
|
52
|
+
|
|
53
|
+
The framework generates a `.framework/app.tsx` on every boot that imports each `*.tsx` file under `src/pages/` (excluding `node_modules`), wraps them in their layout chains, and produces a `<Router routes={…} />` element. A `mount(App, { group })` call in `.framework/main.tsx` mounts it via `react-dom/client`'s `createRoot` (or `hydrateRoot` for SSR pages). The router watches `window.location` + intercepts `<Link>` clicks for client-side nav.
|
|
54
|
+
|
|
55
|
+
You don't write any of this. The CLI regenerates it on every save in dev; the build pipeline freezes it for production.
|
|
56
|
+
|
|
57
|
+
## Embedding the runtime in a foreign host
|
|
58
|
+
|
|
59
|
+
To mount Voltro's reactive runtime *inside* an app that owns its own routing (a Next.js / Remix / existing React shell), skip the generated boot and use the embeddable provider from the light `@voltro/web/runtime` subpath:
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { VoltroRuntimeProvider, type MountedApi } from '@voltro/web/runtime'
|
|
63
|
+
|
|
64
|
+
const apis: ReadonlyArray<MountedApi> = [
|
|
65
|
+
{ name: 'app', group, descriptors, wsUrl: 'wss://your-api.example.com/ws' },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
export const Providers = ({ children }: { children: React.ReactNode }) => (
|
|
69
|
+
<VoltroRuntimeProvider apis={apis}>{children}</VoltroRuntimeProvider>
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Inside it, every `@voltro/client` hook (`useSubscription`, `useMutation`, `useAction`, …) resolves exactly as in a native Voltro app — it builds + supervises the per-api RpcClient-over-WebSocket + runtime + subscription cache and handles reconnect. `children` render immediately (a pending api reads `undefined` until connected — no spinner gate), so a prerendered host hydrates without a mismatch. The `@voltro/web/runtime` subpath pulls only the rpc/socket/runtime graph, not the router/mount/SSR — keeping the host bundle small. (`FrameworkBoot`, the framework's own web boot, is a thin wrapper over this provider.) Consume the framework as the **published** package — a `file:` link to an unbuilt checkout resolves the raw `src` export, which a foreign bundler can't handle.
|
|
74
|
+
|
|
75
|
+
## Conventions
|
|
76
|
+
|
|
77
|
+
| Pattern | Behaviour |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `index.tsx` in a directory | Maps to the directory's URL (no segment for the filename). |
|
|
80
|
+
| `[name].tsx` | Dynamic single segment. Available via `useParams<{ name: string }>()`. |
|
|
81
|
+
| `[...rest].tsx` | Catch-all. `useParams<{ rest: string }>()` joins the captured path with `/`. |
|
|
82
|
+
| `[[...rest]].tsx` | Optional catch-all. Matches both `/foo` and `/foo/bar/baz`. |
|
|
83
|
+
| `(name)/` | Route group. Strips the segment from the URL but layouts inside still apply. |
|
|
84
|
+
| `_*.tsx` | Private — discovery skips it. Useful for helpers next to pages. |
|
|
85
|
+
| `*.island.tsx` | Hydration island. Bundles as a separate chunk; hydrated only when imported. |
|
|
86
|
+
|
|
87
|
+
## When NOT to use file-based routing
|
|
88
|
+
|
|
89
|
+
The pattern works for ~99% of apps. Edge cases:
|
|
90
|
+
|
|
91
|
+
- **Programmatically generated routes** — when you can't know the URL ahead of time. Use `[...slug].tsx` + match inside.
|
|
92
|
+
- **i18n with URL-segment locale** — see [URL strategies](/docs/i18n/url-strategies) for the shipped URL-prefix approach (`/de/docs/foo`).
|
|
93
|
+
- **Cross-tenant subdomains** — handled at the reverse-proxy layer; the file-based router serves one host's paths.
|
|
94
|
+
|
|
95
|
+
For everything else, drop a file + done.
|
|
96
|
+
|
|
97
|
+
## Where to next — building the UI inside the page
|
|
98
|
+
|
|
99
|
+
Routing gets you to a page; the **[Schema-driven UI](/docs/ui/overview)** section
|
|
100
|
+
fills it. It's the other half of the frontend story:
|
|
101
|
+
|
|
102
|
+
- **[Forms & tables](/docs/ui/forms-and-tables)** — `<AutoForm>` binds to a
|
|
103
|
+
mutation, `<DataTable>` to a query; fields + columns come from the
|
|
104
|
+
descriptors' `effect/Schema`, validation + live auto-optimistic for free.
|
|
105
|
+
- **[Reactive components](/docs/ui/reactive-components)** — drop-in workflow
|
|
106
|
+
progress, presence/multiplayer, and AI chat over the durable backend.
|
|
107
|
+
- **[Client utilities](/docs/ui/client-utilities/use-can)** — the bound-hook toolbox
|
|
108
|
+
beyond `useSubscription`/`useMutation`: `useCan`, `useDerived`, `usePreview`,
|
|
109
|
+
`useUndo`, `useProvenance`, `useAsyncValidation`, `useOutbox`,
|
|
110
|
+
`useWindowedSubscription`, and more.
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
<!-- source: en/routing/pages.md -->
|
|
117
|
+
## Pages & dynamic segments
|
|
118
|
+
|
|
119
|
+
_Filesystem → URL mapping, dynamic [id] segments, catch-all [...slug] queries, query params, and private files._
|
|
120
|
+
|
|
121
|
+
A **page** is any `*.tsx` file under `src/pages/` that's not a special file (`layout.tsx`, `error.tsx`, `loading.tsx`, `not-found.tsx`) and doesn't start with `_`. Its default export is the page component; the URL comes from the file path.
|
|
122
|
+
|
|
123
|
+
## A static page
|
|
124
|
+
|
|
125
|
+
```tsx
|
|
126
|
+
// src/pages/about.tsx → /about
|
|
127
|
+
import type { ReactNode } from 'react'
|
|
128
|
+
|
|
129
|
+
export default function About(): ReactNode {
|
|
130
|
+
return (
|
|
131
|
+
<div className="max-w-2xl mx-auto py-12 px-6">
|
|
132
|
+
<h1 className="text-3xl font-bold">About us</h1>
|
|
133
|
+
<p>Voltro Cloud is a framework for shipping multi-tenant SaaS.</p>
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
That's it. Save the file, the CLI's discovery sees it on next save, the page is live at `/about`.
|
|
140
|
+
|
|
141
|
+
## index files
|
|
142
|
+
|
|
143
|
+
`index.tsx` maps to the directory's URL:
|
|
144
|
+
|
|
145
|
+
```text
|
|
146
|
+
src/pages/index.tsx → /
|
|
147
|
+
src/pages/users/index.tsx → /users
|
|
148
|
+
src/pages/admin/index.tsx → /admin
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Dynamic segments
|
|
152
|
+
|
|
153
|
+
Brackets in the filename are dynamic. The captured value comes through `useParams<T>()`:
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
// src/pages/users/[id].tsx → /users/:id
|
|
157
|
+
import { useParams } from '@voltro/web'
|
|
158
|
+
|
|
159
|
+
export default function User() {
|
|
160
|
+
const { id } = useParams<{ id: string }>()
|
|
161
|
+
return <h1>User {id}</h1>
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Multiple dynamic segments in one path:
|
|
166
|
+
|
|
167
|
+
```text
|
|
168
|
+
src/pages/orgs/[orgId]/projects/[projectId].tsx
|
|
169
|
+
// → /orgs/:orgId/projects/:projectId
|
|
170
|
+
|
|
171
|
+
const { orgId, projectId } = useParams<{ orgId: string; projectId: string }>()
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Catch-all queries
|
|
175
|
+
|
|
176
|
+
`[...name]` captures one OR more URL segments as a single param value (joined by `/`):
|
|
177
|
+
|
|
178
|
+
```tsx
|
|
179
|
+
// src/pages/docs/[...slug].tsx → /docs/<anything>
|
|
180
|
+
const { slug } = useParams<{ slug: string }>()
|
|
181
|
+
// /docs/intro/getting-started → slug = "intro/getting-started"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Optional** catch-all (matches the base URL too):
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
// src/pages/docs/[[...slug]].tsx
|
|
188
|
+
// /docs → slug = ""
|
|
189
|
+
// /docs/foo → slug = "foo"
|
|
190
|
+
// /docs/foo/bar → slug = "foo/bar"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Priority
|
|
194
|
+
|
|
195
|
+
When multiple files could match (static, dynamic, catch-all), priority is:
|
|
196
|
+
|
|
197
|
+
1. Static segments win over dynamic.
|
|
198
|
+
2. Dynamic single (`[id]`) wins over catch-all (`[...slug]`).
|
|
199
|
+
3. Optional catch-all (`[[...slug]]`) wins over required catch-all (`[...slug]`) — the optional form scores as more specific, so it matches first.
|
|
200
|
+
|
|
201
|
+
```text
|
|
202
|
+
src/pages/users/index.tsx # /users → wins for /users
|
|
203
|
+
src/pages/users/[id].tsx # /users/:id → wins for /users/42
|
|
204
|
+
src/pages/users/new.tsx # /users/new → wins (static beats dynamic)
|
|
205
|
+
src/pages/[...rest].tsx # everything else
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Query strings
|
|
209
|
+
|
|
210
|
+
Voltro doesn't bake query params into the query — they're orthogonal to the URL pattern:
|
|
211
|
+
|
|
212
|
+
```tsx
|
|
213
|
+
import { useLocation } from '@voltro/web'
|
|
214
|
+
|
|
215
|
+
const Page = () => {
|
|
216
|
+
const pathname = useLocation()
|
|
217
|
+
// …
|
|
218
|
+
// For the search string, parse it from the request URL via useServerRequest()
|
|
219
|
+
// (SSR) or window.location.search (client after hydration).
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
For SSR pages that need server-side query parsing:
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
import { useServerRequest } from '@voltro/web'
|
|
227
|
+
|
|
228
|
+
export const renderMode = 'ssr' as const
|
|
229
|
+
|
|
230
|
+
export default function SearchPage() {
|
|
231
|
+
const req = useServerRequest()
|
|
232
|
+
const q = req
|
|
233
|
+
? new URL(req.url, 'http://x').searchParams.get('q') ?? ''
|
|
234
|
+
: new URLSearchParams(window.location.search).get('q') ?? ''
|
|
235
|
+
// …
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Parse the query string explicitly via `useServerRequest()` on the server and `window.location.search` on the client, as shown above.
|
|
240
|
+
|
|
241
|
+
## Private files
|
|
242
|
+
|
|
243
|
+
Files starting with `_` are skipped by discovery — they're helpers next to pages:
|
|
244
|
+
|
|
245
|
+
```text
|
|
246
|
+
src/pages/
|
|
247
|
+
├── _components/
|
|
248
|
+
│ └── UserCard.tsx # NOT a route — import from siblings
|
|
249
|
+
├── users/
|
|
250
|
+
│ ├── _helpers.ts # NOT a route
|
|
251
|
+
│ └── [id].tsx # → /users/:id
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
You can also use the colocation pattern: keep page-specific components in a directory named with `_` prefix.
|
|
255
|
+
|
|
256
|
+
## Trailing slashes
|
|
257
|
+
|
|
258
|
+
The canonical form is **no trailing slash** — always link with `<Link to="/about">`, not `<Link to="/about/">`.
|
|
259
|
+
|
|
260
|
+
The framework does NOT emit a trailing-slash redirect on its own. If you need `/about/` → `/about` normalisation (for SEO), configure a 301 redirect at your reverse proxy.
|
|
261
|
+
|
|
262
|
+
## What pages CAN'T do
|
|
263
|
+
|
|
264
|
+
- **Live outside `src/pages/`.** Discovery walks one root. Helpers + components go elsewhere; pages go here.
|
|
265
|
+
- **Have multiple default exports.** One page per file.
|
|
266
|
+
- **Be `.ts` files.** Pages must be `.tsx` — React components only.
|
|
267
|
+
- **Be discovered via dynamic import.** The CLI generates the import statements at boot; runtime adds need a re-discover (which `voltro dev` does on save).
|
|
268
|
+
|
|
269
|
+
## Where to read next
|
|
270
|
+
|
|
271
|
+
- [Layouts & route groups](/docs/routing/layouts) — wrap pages in shared chrome
|
|
272
|
+
- [Loaders & meta](/docs/routing/loaders-and-meta) — server-side data + `<head>` tags
|
|
273
|
+
- [Render modes](/docs/routing/render-modes) — `static` / `ssr` / `isr`
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
<!-- source: en/routing/layouts.md -->
|
|
280
|
+
## Layouts & route groups
|
|
281
|
+
|
|
282
|
+
_layout.tsx, error.tsx, loading.tsx, not-found.tsx, route groups (group)/, and how the chain composes._
|
|
283
|
+
|
|
284
|
+
A **layout** wraps every page in its subtree. Drop a `layout.tsx` in a directory and every page below it gets wrapped — outer layouts compose around inner ones automatically.
|
|
285
|
+
|
|
286
|
+
The same pattern handles error boundaries (`error.tsx`), pending UI (`loading.tsx`), and 404 fallbacks (`not-found.tsx`). These are the four **special files**; discovery treats them differently from regular pages.
|
|
287
|
+
|
|
288
|
+
## Root layout
|
|
289
|
+
|
|
290
|
+
```tsx
|
|
291
|
+
// src/pages/layout.tsx — wraps EVERY page
|
|
292
|
+
import type { ReactNode } from 'react'
|
|
293
|
+
import './globals.css'
|
|
294
|
+
|
|
295
|
+
export default function Layout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
296
|
+
return (
|
|
297
|
+
<div className="min-h-screen bg-background text-foreground">
|
|
298
|
+
<header>{/* topbar */}</header>
|
|
299
|
+
<main>{children}</main>
|
|
300
|
+
<footer>{/* footer */}</footer>
|
|
301
|
+
</div>
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Conventions:
|
|
307
|
+
|
|
308
|
+
- **Don't render `<html>`/`<head>`/`<body>`.** The framework's `index.html` shell owns those. Rendering them inside React puts them under `#root` + the browser unwraps them, breaking the document structure.
|
|
309
|
+
- **Set `<html class>` via `theme:` in `app.config.ts`.** Bakes the dark/light class into the shell before first paint.
|
|
310
|
+
- **Set `<title>` + meta via the page's `meta` export.** See [Loaders & meta](/docs/routing/loaders-and-meta).
|
|
311
|
+
|
|
312
|
+
## Nested layouts
|
|
313
|
+
|
|
314
|
+
```text
|
|
315
|
+
src/pages/
|
|
316
|
+
├── layout.tsx # outer (every page)
|
|
317
|
+
├── about.tsx # /about → wrapped in outer layout
|
|
318
|
+
└── dashboard/
|
|
319
|
+
├── layout.tsx # nested (only /dashboard/*)
|
|
320
|
+
├── index.tsx # /dashboard
|
|
321
|
+
└── settings.tsx # /dashboard/settings
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
For `/dashboard/settings`, the React tree is:
|
|
325
|
+
|
|
326
|
+
```text
|
|
327
|
+
<OuterLayout>
|
|
328
|
+
<DashboardLayout>
|
|
329
|
+
<Settings />
|
|
330
|
+
</DashboardLayout>
|
|
331
|
+
</OuterLayout>
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Outer layouts compose around inner ones. Each layout's state survives navigation **within** its scope — moving from `/dashboard` to `/dashboard/settings` doesn't unmount `DashboardLayout`.
|
|
335
|
+
|
|
336
|
+
## Route groups
|
|
337
|
+
|
|
338
|
+
A directory in `(parentheses)` does NOT contribute a URL segment, but its layout still applies. Useful when you want a layout for a logical group of pages without nesting their URLs.
|
|
339
|
+
|
|
340
|
+
```text
|
|
341
|
+
src/pages/
|
|
342
|
+
├── (marketing)/
|
|
343
|
+
│ ├── layout.tsx # marketing-scoped chrome
|
|
344
|
+
│ ├── index.tsx # /
|
|
345
|
+
│ ├── pricing.tsx # /pricing
|
|
346
|
+
│ └── about.tsx # /about
|
|
347
|
+
└── (app)/
|
|
348
|
+
├── layout.tsx # authenticated app chrome
|
|
349
|
+
├── dashboard.tsx # /dashboard
|
|
350
|
+
└── settings.tsx # /settings
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Marketing pages get one layout; authenticated app pages get another; the URLs stay flat.
|
|
354
|
+
|
|
355
|
+
Use this when:
|
|
356
|
+
|
|
357
|
+
- The marketing landing + product app share root URL paths but have completely different chrome.
|
|
358
|
+
- You want layout state to NOT persist across logical sections (moving from `/about` to `/dashboard` unmounts everything).
|
|
359
|
+
|
|
360
|
+
## Error boundaries
|
|
361
|
+
|
|
362
|
+
```tsx
|
|
363
|
+
// src/pages/error.tsx — catches errors from any page below
|
|
364
|
+
import type { ReactNode } from 'react'
|
|
365
|
+
|
|
366
|
+
interface ErrorProps {
|
|
367
|
+
readonly error: Error
|
|
368
|
+
readonly reset: () => void
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export default function ErrorPage({ error, reset }: ErrorProps): ReactNode {
|
|
372
|
+
return (
|
|
373
|
+
<div className="text-center py-12">
|
|
374
|
+
<h1 className="text-2xl font-bold mb-2">Something went wrong</h1>
|
|
375
|
+
<p className="text-muted-foreground mb-6">{error.message}</p>
|
|
376
|
+
<button onClick={reset}>Try again</button>
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
- Catches errors from page renders + loaders + any descendant's React tree.
|
|
383
|
+
- `reset()` re-renders the boundary — call after fixing whatever caused the throw.
|
|
384
|
+
- Scoped: `src/pages/dashboard/error.tsx` only catches errors from `/dashboard/*`.
|
|
385
|
+
|
|
386
|
+
## Pending UI (opt-in)
|
|
387
|
+
|
|
388
|
+
Navigation is **deferred** by default: clicking a link keeps the CURRENT page on screen until the target route's `loader`s settle, then swaps. There is **no full-screen loading overlay**. Background progress shows in the devtools button (dev) or a minimal corner indicator (production) — both read the same status bus.
|
|
389
|
+
|
|
390
|
+
`loading.tsx` is an **opt-in** override for one route: export it (or a page-level `Pending`) ONLY when you want that route to swap in immediately and show a skeleton instead of holding the previous page.
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
// src/pages/dashboard/loading.tsx — opt-in skeleton for /dashboard/*
|
|
394
|
+
export default function Loading(): ReactNode {
|
|
395
|
+
return <div className="animate-pulse">Loading…</div>
|
|
396
|
+
}
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`loading.tsx` is driven by in-flight loaders, not by subscriptions — a `useSubscription` returns `data: undefined` until its first snapshot rather than suspending. Render that empty state inside the page itself.
|
|
400
|
+
|
|
401
|
+
Scoped like `error.tsx`.
|
|
402
|
+
|
|
403
|
+
## Not-found
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
// src/pages/not-found.tsx — 404 fallback
|
|
407
|
+
export default function NotFound(): ReactNode {
|
|
408
|
+
return (
|
|
409
|
+
<div className="text-center py-12">
|
|
410
|
+
<h1>Page not found</h1>
|
|
411
|
+
<a href="/">← Home</a>
|
|
412
|
+
</div>
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Scoped: `src/pages/dashboard/not-found.tsx` catches 404s only for URLs starting with `/dashboard/`. Useful for tenant-specific 404 messaging.
|
|
418
|
+
|
|
419
|
+
## The default fallback chrome (and localizing it)
|
|
420
|
+
|
|
421
|
+
Until you supply your own `error.tsx` / `not-found.tsx`, the router renders a built-in diagnostic chrome — a runtime-error card (error name, stack, Retry / Reload / Copy) and a 404 card. It's a dev-diagnostic surface, not app UI, so it ships full detail regardless of environment. Two ways to change it:
|
|
422
|
+
|
|
423
|
+
- **Replace it** — export your own `error.tsx` / `not-found.tsx` (above), or pass `errorFallback` / `notFound` components to the `<Router>`. Total control.
|
|
424
|
+
- **Just relabel it** — wrap the app in `<FallbackStringsProvider>` to override the built-in chrome's English strings without rebuilding the components (e.g. to localize "Retry" / "Reload page" / "No page for this URL"):
|
|
425
|
+
|
|
426
|
+
```tsx
|
|
427
|
+
import { FallbackStringsProvider } from '@voltro/web'
|
|
428
|
+
|
|
429
|
+
<FallbackStringsProvider
|
|
430
|
+
strings={{
|
|
431
|
+
error: { retry: 'Wiederholen', reload: 'Seite neu laden', copy: 'Fehler kopieren' },
|
|
432
|
+
notFound: { heading: 'Keine Seite für diese URL' },
|
|
433
|
+
}}
|
|
434
|
+
>
|
|
435
|
+
{/* your app */}
|
|
436
|
+
</FallbackStringsProvider>
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Overrides deep-merge onto the English defaults — supply only the keys you change; the rest stay English. A per-component `strings` prop on `DefaultErrorFallback` / `DefaultNotFound` wins over the provider for a one-off. (Mirrors `<UiStringsProvider>` for the `@voltro/ui` kit.)
|
|
440
|
+
|
|
441
|
+
## How the framework composes them
|
|
442
|
+
|
|
443
|
+
For a request to `/dashboard/settings`, the framework walks the page tree:
|
|
444
|
+
|
|
445
|
+
1. Find the matching leaf — `dashboard/settings.tsx`.
|
|
446
|
+
2. Walk up the directory tree, collecting every directory's special files in order.
|
|
447
|
+
3. Build the chain: outermost layout → next layout → … → leaf page.
|
|
448
|
+
4. Wrap each layer's `error.tsx` as a React Error Boundary around the next layer.
|
|
449
|
+
5. Render.
|
|
450
|
+
|
|
451
|
+
The generated `.framework/app.tsx` records this chain explicitly per route — you can `cat` it to see the result.
|
|
452
|
+
|
|
453
|
+
## Limits
|
|
454
|
+
|
|
455
|
+
- **One `layout.tsx` per directory.** Multiple would be ambiguous.
|
|
456
|
+
- **Layouts can't be async functions.** Use a `loader` for data + read it via `useLoaderData`.
|
|
457
|
+
- **Error boundaries don't catch loader errors.** Loader errors render the page's `error.tsx`; throwing inside the page's render does too. Both flow through the same boundary.
|
|
458
|
+
- **Layouts can call `useLocation()`, `useParams()`, `useServerRequest()`.** They're React components like any other.
|
|
459
|
+
|
|
460
|
+
## Where to read next
|
|
461
|
+
|
|
462
|
+
- [Render modes](/docs/routing/render-modes) — how `static` / `ssr` / `isr` interact with layouts
|
|
463
|
+
- [Loaders & meta](/docs/routing/loaders-and-meta) — server-side data + page-level `<head>` tags
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
---
|
|
468
|
+
|
|
469
|
+
<!-- source: en/routing/render-modes.md -->
|
|
470
|
+
## Render modes
|
|
471
|
+
|
|
472
|
+
_static (SSG) vs ssr vs isr — when each runs, what it caches, and how to pick._
|
|
473
|
+
|
|
474
|
+
Every page declares a `renderMode`. The mode controls **when** the HTML is produced — at build time, on every request, or build-once-revalidate-occasionally.
|
|
475
|
+
|
|
476
|
+
```tsx
|
|
477
|
+
export const renderMode = 'static' as const // 'static' | 'ssr' | 'isr'
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
## The three modes
|
|
481
|
+
|
|
482
|
+
| Mode | When HTML is produced | Cached? | Best for |
|
|
483
|
+
|---|---|---|---|
|
|
484
|
+
| `static` *(default)* | `voltro build` time | Forever | Marketing pages, docs, anything that doesn't change per-request |
|
|
485
|
+
| `ssr` | Every request | Never | Authenticated dashboards, search results, anything cookie-driven |
|
|
486
|
+
| `isr` | First request after build, then on revalidate | Per-key in-memory or Postgres | News feeds, listings, dashboards that change but not per-user |
|
|
487
|
+
|
|
488
|
+
## static (SSG)
|
|
489
|
+
|
|
490
|
+
```tsx
|
|
491
|
+
// src/pages/about.tsx
|
|
492
|
+
export const renderMode = 'static' as const
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
At `voltro build`:
|
|
496
|
+
|
|
497
|
+
1. The framework runs the page's render once (with `useServerRequest()` returning `null`).
|
|
498
|
+
2. The output HTML lands at `dist/about/index.html`.
|
|
499
|
+
3. `voltro start` serves the file directly — no React runs on the server.
|
|
500
|
+
|
|
501
|
+
For dynamic patterns, export `getStaticPaths` to enumerate every URL to pre-render:
|
|
502
|
+
|
|
503
|
+
```tsx
|
|
504
|
+
// src/pages/blog/[slug].tsx
|
|
505
|
+
export const renderMode = 'static' as const
|
|
506
|
+
|
|
507
|
+
export const getStaticPaths = async () => [
|
|
508
|
+
{ params: { slug: 'first-post' } },
|
|
509
|
+
{ params: { slug: 'second-post' } },
|
|
510
|
+
]
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
One HTML file per entry lands in `dist/blog/first-post/index.html` etc.
|
|
514
|
+
|
|
515
|
+
Pages that don't enumerate (dynamic without `getStaticPaths`) fall through to the SPA shell — the client-side router takes over.
|
|
516
|
+
|
|
517
|
+
## ssr
|
|
518
|
+
|
|
519
|
+
```tsx
|
|
520
|
+
export const renderMode = 'ssr' as const
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
On every request:
|
|
524
|
+
|
|
525
|
+
1. `voltro start` matches the URL → finds your page.
|
|
526
|
+
2. Calls the loader (if any) with the request's params + headers + cookies.
|
|
527
|
+
3. Renders the React tree to HTML.
|
|
528
|
+
4. Returns it.
|
|
529
|
+
|
|
530
|
+
Use SSR for:
|
|
531
|
+
|
|
532
|
+
- **Authenticated pages** that read the session cookie via `useServerRequest()`.
|
|
533
|
+
- **Personalised content** — recommendations, "your" anything.
|
|
534
|
+
- **Search result pages** — the query string changes per request.
|
|
535
|
+
|
|
536
|
+
Cost: every request triggers a fresh render. For very high-traffic pages, prefer ISR.
|
|
537
|
+
|
|
538
|
+
### Streaming SSR
|
|
539
|
+
|
|
540
|
+
By default an `ssr` page renders with `renderToString` — the whole document is built in memory, then sent. The server-side SSR helpers in `@voltro/web/ssr` also expose a **streaming** path, `renderPageToStream`, built on React's `renderToPipeableStream`. It flushes the shell (and each `<Suspense>` boundary as it resolves) instead of buffering the whole page, so the browser gets the first bytes sooner:
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
import { renderPageToStream } from '@voltro/web/ssr'
|
|
544
|
+
|
|
545
|
+
const { stream, meta } = renderPageToStream({
|
|
546
|
+
descriptor,
|
|
547
|
+
params,
|
|
548
|
+
pathname,
|
|
549
|
+
loaderData,
|
|
550
|
+
bootstrapModules: ['/assets/client.js'],
|
|
551
|
+
onShellReady: () => {
|
|
552
|
+
// meta is resolved synchronously — write <head> before piping the body.
|
|
553
|
+
res.setHeader('content-type', 'text/html')
|
|
554
|
+
res.write(`<!doctype html><html><head>${renderMetaToHtml(meta)}</head><body><div id="root">`)
|
|
555
|
+
stream.pipe(res)
|
|
556
|
+
},
|
|
557
|
+
onAllReady: () => res.end('</div></body></html>'),
|
|
558
|
+
onShellError: (err) => { res.statusCode = 500; res.end('Internal error') },
|
|
559
|
+
})
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
`renderPageToStream` takes the same options as `renderPageToHtml` plus the stream callbacks (`onShellReady` / `onAllReady` / `onShellError`) and `bootstrapModules`. Both paths compose the same providers and resolve `meta` synchronously, so the sync string render and the streaming render produce the same tree — the difference is time-to-first-byte and native Suspense streaming. Most apps never call these directly; the `voltro start` pipeline uses them under the hood.
|
|
563
|
+
|
|
564
|
+
## isr (incremental static regeneration)
|
|
565
|
+
|
|
566
|
+
```tsx
|
|
567
|
+
export const renderMode = 'isr' as const
|
|
568
|
+
export const revalidate = '60 seconds' // re-render when older than this
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
A bare number is read as **seconds** (Next.js compat) — `revalidate = 60` means 60 seconds, NOT milliseconds. Use the string form (`'60 seconds'`, `'5 minutes'`, `'1 hour'`) for clarity.
|
|
572
|
+
|
|
573
|
+
Behaviour:
|
|
574
|
+
|
|
575
|
+
| Request | Action |
|
|
576
|
+
|---|---|
|
|
577
|
+
| First | MISS — render, store in cache, serve. |
|
|
578
|
+
| Subsequent (cache fresh) | HIT — serve from cache. |
|
|
579
|
+
| After revalidate window | MISS — re-render, store, serve. |
|
|
580
|
+
| With `staleWhileRevalidate` | STALE — serve cached HTML immediately, kick off background refresh. |
|
|
581
|
+
|
|
582
|
+
```tsx
|
|
583
|
+
export const renderMode = 'isr' as const
|
|
584
|
+
export const revalidate = '60 seconds'
|
|
585
|
+
export const staleWhileRevalidate = '60 seconds' // serve stale while refreshing in bg
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Cache backends:
|
|
589
|
+
|
|
590
|
+
- `memory` *(default)* — in-process, doesn't survive restarts.
|
|
591
|
+
- `postgres` — `SSR_CACHE=postgres`. Survives restarts, shared across api instances.
|
|
592
|
+
|
|
593
|
+
## Tenant-aware ISR
|
|
594
|
+
|
|
595
|
+
For multi-tenant ISR (each tenant gets its own cache entry):
|
|
596
|
+
|
|
597
|
+
```tsx
|
|
598
|
+
export const renderMode = 'isr' as const
|
|
599
|
+
export const revalidate = '60 seconds'
|
|
600
|
+
export const tenantAware = true
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
The cache key becomes `${pathname}|tenant=${tenant}`, where `tenant` is the request's `x-tenant` header (falling back to `anonymous`). Tenant A's cached HTML never serves to tenant B.
|
|
604
|
+
|
|
605
|
+
## CDC-invalidated ISR
|
|
606
|
+
|
|
607
|
+
When a specific DB write should invalidate the cache (instead of waiting for the revalidate window):
|
|
608
|
+
|
|
609
|
+
```tsx
|
|
610
|
+
export const renderMode = 'isr' as const
|
|
611
|
+
export const cacheInvalidatesOn = ['posts', 'comments'] // tables to watch
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
The framework reads Postgres logical replication; writes to `posts` or `comments` invalidate every cached HTML for this query. New requests rebuild the page from the current data.
|
|
615
|
+
|
|
616
|
+
Requires `SSR_CACHE=postgres` and a `wal_level=logical` Postgres.
|
|
617
|
+
|
|
618
|
+
## Picking between them
|
|
619
|
+
|
|
620
|
+
| You have… | Use |
|
|
621
|
+
|---|---|
|
|
622
|
+
| A truly static page (marketing copy) | `static` |
|
|
623
|
+
| A list of known pre-publishable URLs (blog posts) | `static` + `getStaticPaths` |
|
|
624
|
+
| A page that changes per user (dashboard, account) | `ssr` |
|
|
625
|
+
| A search results page (URL query → result) | `ssr` |
|
|
626
|
+
| A blog index that changes when posts are added | `isr` + `cacheInvalidatesOn: ['posts']` |
|
|
627
|
+
| A multi-tenant marketing site (`acme.com/[tenant]/pricing`) | `isr` + `tenantAware: true` |
|
|
628
|
+
| A status page with 30s-stale acceptable | `isr` + `revalidate = '30 seconds'` |
|
|
629
|
+
|
|
630
|
+
## What about `interactive`?
|
|
631
|
+
|
|
632
|
+
`interactive` is **orthogonal** to `renderMode` — it controls how much JS runs in the browser. See [Islands](/docs/routing/islands).
|
|
633
|
+
|
|
634
|
+
| `interactive` | What's hydrated |
|
|
635
|
+
|---|---|
|
|
636
|
+
| `'none'` | Nothing — pure HTML. |
|
|
637
|
+
| `'islands'` | Only `*.island.tsx` files. |
|
|
638
|
+
| `'full'` *(default)* | Whole page. |
|
|
639
|
+
|
|
640
|
+
Combinations:
|
|
641
|
+
|
|
642
|
+
| `renderMode` × `interactive` | When |
|
|
643
|
+
|---|---|
|
|
644
|
+
| `static` + `none` | Marketing pages, blog posts. Zero JS. |
|
|
645
|
+
| `static` + `full` | SSG with full client-side nav. Docs sites. |
|
|
646
|
+
| `ssr` + `full` | Dashboards. The most "Next.js-like" mode. |
|
|
647
|
+
| `isr` + `islands` | News feeds with a "like" button island. |
|
|
648
|
+
|
|
649
|
+
## What gets served when
|
|
650
|
+
|
|
651
|
+
A request to `/foo`:
|
|
652
|
+
|
|
653
|
+
1. **Pre-rendered HTML exists at `dist/foo/index.html`?** Serve it. (static + isr-already-cached.)
|
|
654
|
+
2. **No pre-render, page is `ssr`?** Render fresh, serve.
|
|
655
|
+
3. **No pre-render, page is `isr`?** Cache lookup → MISS → render → store → serve.
|
|
656
|
+
4. **No pre-render, page is `static`?** Serve the SPA shell — the client router takes over.
|
|
657
|
+
|
|
658
|
+
That last case is how dynamic `static` routes work in dev / when `getStaticPaths` didn't include the URL.
|
|
659
|
+
|
|
660
|
+
## What doesn't work
|
|
661
|
+
|
|
662
|
+
- **Switching `renderMode` per request.** It's a static module export — one value per build.
|
|
663
|
+
- **`renderMode: 'ssr'` without `voltro start`.** SSR requires the production server. Dev mode runs everything client-side regardless.
|
|
664
|
+
- **`isr` with `cacheInvalidatesOn` against memory cache.** Memory cache is per-process; CDC events fire across processes. Use `SSR_CACHE=postgres`.
|
|
665
|
+
|
|
666
|
+
## Where to read next
|
|
667
|
+
|
|
668
|
+
- [Loaders & meta](/docs/routing/loaders-and-meta) — fetch data before render, inject `<head>` tags
|
|
669
|
+
- [Islands](/docs/routing/islands) — pages that ship 0 JS except for explicit islands
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
---
|
|
674
|
+
|
|
675
|
+
<!-- source: en/routing/loaders-and-meta.md -->
|
|
676
|
+
## Loaders & meta
|
|
677
|
+
|
|
678
|
+
_Server-side data fetch via `loader`, page-level `<head>` tags via `meta`, and how the build pipeline runs both._
|
|
679
|
+
|
|
680
|
+
A **loader** is the page's server-side data hook. It runs before the React render (during SSR, during SSG, or per-request for ISR/SSR), and its result lands in `useLoaderData<T>()`. **Meta** is a sibling export that produces `<title>` + `<meta>` tags.
|
|
681
|
+
|
|
682
|
+
Both are static module exports — the framework discovers them, the build pipeline runs them.
|
|
683
|
+
|
|
684
|
+
## A loader
|
|
685
|
+
|
|
686
|
+
```tsx
|
|
687
|
+
// src/pages/notes/[id].tsx
|
|
688
|
+
import { useLoaderData } from '@voltro/web'
|
|
689
|
+
|
|
690
|
+
interface Note {
|
|
691
|
+
readonly id: string
|
|
692
|
+
readonly title: string
|
|
693
|
+
readonly body: string
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export const renderMode = 'ssr' as const
|
|
697
|
+
|
|
698
|
+
export const loader = async ({ params, headers }: {
|
|
699
|
+
params: { id: string }
|
|
700
|
+
headers: Readonly<Record<string, string>>
|
|
701
|
+
}): Promise<Note> => {
|
|
702
|
+
// Server-side fetch — runs on the Node side, never in the browser.
|
|
703
|
+
const res = await fetch(`${INTERNAL_API}/notes/${params.id}`, {
|
|
704
|
+
headers: { cookie: headers.cookie ?? '' },
|
|
705
|
+
})
|
|
706
|
+
if (!res.ok) throw new Error(`note ${params.id}: ${res.status}`)
|
|
707
|
+
return await res.json()
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export default function NotePage(): ReactNode {
|
|
711
|
+
const note = useLoaderData<Note>()
|
|
712
|
+
return (
|
|
713
|
+
<article>
|
|
714
|
+
<h1>{note.title}</h1>
|
|
715
|
+
<p>{note.body}</p>
|
|
716
|
+
</article>
|
|
717
|
+
)
|
|
718
|
+
}
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
`useLoaderData<T>()` returns the loader's resolved value, typed via the generic.
|
|
722
|
+
|
|
723
|
+
## When loaders run
|
|
724
|
+
|
|
725
|
+
| renderMode | When loader runs |
|
|
726
|
+
|---|---|
|
|
727
|
+
| `static` | At `voltro build` time, once. Result baked into HTML. |
|
|
728
|
+
| `ssr` | Every request. |
|
|
729
|
+
| `isr` | On cache MISS (re-runs when cache stale). |
|
|
730
|
+
|
|
731
|
+
For `static` pages with `getStaticPaths`, the loader runs once per enumerated path.
|
|
732
|
+
|
|
733
|
+
## Loader arguments
|
|
734
|
+
|
|
735
|
+
```ts
|
|
736
|
+
export const loader = async (ctx: {
|
|
737
|
+
readonly params: Readonly<Record<string, string>> // URL params from [name] segments
|
|
738
|
+
readonly pathname: string // matched path (no query string)
|
|
739
|
+
readonly signal: AbortSignal // Aborts if the client disconnects mid-render
|
|
740
|
+
readonly headers?: Readonly<Record<string, string>> // Request headers (SSR/ISR only — empty for SSG/client)
|
|
741
|
+
// Call the backend rpc directly — present ONLY when the loader runs
|
|
742
|
+
// server-side (`voltro start` / `voltro dev` SSR); `undefined`
|
|
743
|
+
// client-side. Resolves a query's FIRST (initial) snapshot.
|
|
744
|
+
readonly query?: <T = unknown>(tag: string, input?: Record<string, unknown>) => Promise<T>
|
|
745
|
+
}) => Promise<unknown>
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
The loader context carries `pathname`, not a `request` object. For the query string during SSR, read it from `useServerRequest().url` inside the component.
|
|
749
|
+
|
|
750
|
+
Use `signal` for any fetch that could outlive the request — pass it to `fetch(url, { signal })` so cancelled requests don't waste CPU.
|
|
751
|
+
|
|
752
|
+
## Fetching backend data with `ctx.query`
|
|
753
|
+
|
|
754
|
+
Instead of hand-rolling a `fetch(INTERNAL_API/...)`, a server-side loader can call the backend rpc directly through `ctx.query` — the same query tags the client subscribes to, resolved to their initial snapshot:
|
|
755
|
+
|
|
756
|
+
```tsx
|
|
757
|
+
// src/pages/notes/[id].tsx
|
|
758
|
+
import { useSubscription } from '@voltro/client'
|
|
759
|
+
import { useLoaderData, type PageMeta } from '@voltro/web'
|
|
760
|
+
|
|
761
|
+
interface Note { readonly id: string; readonly title: string; readonly body: string }
|
|
762
|
+
|
|
763
|
+
export const renderMode = 'ssr' as const
|
|
764
|
+
|
|
765
|
+
export const loader = async ({ params, query }: {
|
|
766
|
+
params: { id: string }
|
|
767
|
+
query?: <T>(tag: string, input?: Record<string, unknown>) => Promise<T>
|
|
768
|
+
}) => {
|
|
769
|
+
// `query` is undefined client-side — guard it. SSR forwards the
|
|
770
|
+
// request's cookie, so the api resolves the SAME Subject + tenant
|
|
771
|
+
// as the WebSocket path.
|
|
772
|
+
const note = query ? await query<Note>('notes.get', { id: params.id }) : undefined
|
|
773
|
+
return { note }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export const meta = ({ loaderData }: { loaderData: { note?: Note } }): PageMeta => ({
|
|
777
|
+
title: loaderData.note ? `${loaderData.note.title} — Notes` : 'Notes',
|
|
778
|
+
description: loaderData.note?.body.slice(0, 140) ?? '',
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
export default function NotePage() {
|
|
782
|
+
const { note: ssrNote } = useLoaderData<{ note?: Note }>()
|
|
783
|
+
// Live updates after hydration: useSubscription takes over from the
|
|
784
|
+
// SSR snapshot. The loader gave us first-paint HTML + correct meta;
|
|
785
|
+
// the subscription keeps it fresh.
|
|
786
|
+
const { data } = useSubscription<Note>('app', 'notes.get', { id: ssrNote?.id ?? '' }, { skip: !ssrNote })
|
|
787
|
+
const note = data ?? ssrNote
|
|
788
|
+
if (!note) return null
|
|
789
|
+
return <article><h1>{note.title}</h1><p>{note.body}</p></article>
|
|
790
|
+
}
|
|
791
|
+
```
|
|
792
|
+
|
|
793
|
+
Two rules that fall out of this:
|
|
794
|
+
|
|
795
|
+
- **`query` is server-only.** It's `undefined` for client-side loader invocations (SPA navigation re-runs the loader in the browser). Guard it (`query ? … : undefined`) and use `useSubscription` in the component for the reactive, after-hydration path. The loader's `query` is for SSR first-paint + `meta`.
|
|
796
|
+
- **It forwards the request cookie.** The HTTP rpc resolves the same Subject + tenant as the WebSocket connection would, so tenant-scoped queries return the right rows during SSR.
|
|
797
|
+
|
|
798
|
+
Under the hood, `ctx.query` is a one-shot `POST /rpc` call (see [Wire protocol](/docs/data/wire-protocol#http-one-shot-rpc-post-rpc)).
|
|
799
|
+
|
|
800
|
+
## Errors from loaders
|
|
801
|
+
|
|
802
|
+
If the loader throws, the framework:
|
|
803
|
+
|
|
804
|
+
1. Catches the throw.
|
|
805
|
+
2. Renders the page's `error.tsx` (or the nearest ancestor's) with the error.
|
|
806
|
+
3. Serves the resulting HTML.
|
|
807
|
+
|
|
808
|
+
For 404s, throw a `NotFoundError`:
|
|
809
|
+
|
|
810
|
+
```ts
|
|
811
|
+
import { NotFoundError } from '@voltro/web'
|
|
812
|
+
|
|
813
|
+
export const loader = async ({ params, query }) => {
|
|
814
|
+
const note = query ? await query('notes.get', { id: params.id }) : undefined
|
|
815
|
+
if (!note) throw new NotFoundError(`note ${params.id}`)
|
|
816
|
+
return note
|
|
817
|
+
}
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
The framework returns a 404 status + renders `not-found.tsx` for that subtree. The `notFound()` helper is throwing sugar for the same thing — `const note = (await load()) ?? notFound('note ' + params.id)` reads well when the not-found is inline.
|
|
821
|
+
|
|
822
|
+
## Meta
|
|
823
|
+
|
|
824
|
+
```tsx
|
|
825
|
+
import type { PageMeta } from '@voltro/web'
|
|
826
|
+
|
|
827
|
+
export const meta: PageMeta = {
|
|
828
|
+
title: 'Notes — Voltro',
|
|
829
|
+
description: 'All your notes, in one place.',
|
|
830
|
+
tags: [
|
|
831
|
+
{ property: 'og:title', content: 'Voltro Notes' },
|
|
832
|
+
{ property: 'og:description', content: 'All your notes, in one place.' },
|
|
833
|
+
{ property: 'og:image', content: '/og.svg' },
|
|
834
|
+
{ name: 'twitter:card', content: 'summary_large_image' },
|
|
835
|
+
],
|
|
836
|
+
}
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
The framework injects these into the HTML's `<head>` at build / SSR time:
|
|
840
|
+
|
|
841
|
+
```html
|
|
842
|
+
<head>
|
|
843
|
+
<title>Notes — Voltro</title>
|
|
844
|
+
<meta name="description" content="All your notes, in one place." />
|
|
845
|
+
<meta property="og:title" content="Voltro Notes" />
|
|
846
|
+
<meta property="og:image" content="/og.svg" />
|
|
847
|
+
…
|
|
848
|
+
</head>
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
### The default document title
|
|
852
|
+
|
|
853
|
+
A page's `meta.title` overrides the tab title on navigation. Before any page
|
|
854
|
+
sets one — the initial HTML shell, a route with no `meta`, an error page — the
|
|
855
|
+
browser tab shows the app's **default title**, set in `app.config.ts`:
|
|
856
|
+
|
|
857
|
+
```ts
|
|
858
|
+
export default {
|
|
859
|
+
type: 'web' as const,
|
|
860
|
+
name: 'AcmeDashboard', // internal identifier (package/port lookup) — PascalCase by convention
|
|
861
|
+
title: 'Acme', // human document title baked into the HTML shell
|
|
862
|
+
}
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
`title` is the default `<title>`. It is distinct from `name`, the app's internal
|
|
866
|
+
identifier — leaking that PascalCase identifier into the tab reads as a dev
|
|
867
|
+
artefact. When `title` is unset the shell falls back to `name`, so set a real
|
|
868
|
+
product title on any app users actually see. Per-page `meta.title` still wins
|
|
869
|
+
wherever a page provides one.
|
|
870
|
+
|
|
871
|
+
## Dynamic meta from params + loader data + locale
|
|
872
|
+
|
|
873
|
+
When the meta depends on the URL or on what the loader fetched, export `meta` as a function. It receives a single object `{ params, loaderData, locale }` and runs at build / SSR time after the loader resolves:
|
|
874
|
+
|
|
875
|
+
```tsx
|
|
876
|
+
export const meta = ({ params }: { params: { id: string } }): PageMeta => ({
|
|
877
|
+
title: `Note ${params.id} — Voltro`,
|
|
878
|
+
description: '…',
|
|
879
|
+
})
|
|
880
|
+
```
|
|
881
|
+
|
|
882
|
+
Reading the loader's result lets the title/description reflect fetched fields — the canonical "page title is the note's title" case:
|
|
883
|
+
|
|
884
|
+
```tsx
|
|
885
|
+
export const loader = async ({ params, query }) => ({
|
|
886
|
+
note: query ? await query('notes.get', { id: params.id }) : undefined,
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
export const meta = ({ loaderData }: { loaderData: { note?: { title: string; body: string } } }): PageMeta => ({
|
|
890
|
+
title: loaderData.note ? `${loaderData.note.title} — Voltro` : 'Voltro',
|
|
891
|
+
description: loaderData.note?.body.slice(0, 140) ?? '',
|
|
892
|
+
})
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
The third context field — `locale: string` — is the active i18n locale for this render. For `[locale]/…` routes it carries the URL-prefix locale (`'de'` on `/de/notes/42`). For bare-path routes it carries the active locale from the framework's `voltro:lang` cookie when set — so **cookie-based i18n works too** (an authed dashboard with no `[locale]` URL still gets a translated `<title>` that tracks the language switch) — otherwise the app's `defaultLocale`. Use it to localise title / description / canonical / OG per locale at SSG time so search engines see translated head tags on every variant, and to give cookie-i18n pages a translated tab title:
|
|
896
|
+
|
|
897
|
+
```tsx
|
|
898
|
+
import { getCatalog } from '../lib/locale'
|
|
899
|
+
import { localeCanonicalUrl } from '../lib/seo'
|
|
900
|
+
|
|
901
|
+
export const meta = ({ locale }: { locale: string }): PageMeta => {
|
|
902
|
+
const c = getCatalog(locale)
|
|
903
|
+
return {
|
|
904
|
+
title: c['seo.notes.title'] as string,
|
|
905
|
+
description: c['seo.notes.description'] as string,
|
|
906
|
+
canonical: localeCanonicalUrl('/notes', locale),
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
```
|
|
910
|
+
|
|
911
|
+
`meta(ctx)` runs once per (page × locale) at build time. The full per-locale SSG flow — `[locale]/…` mirror routes, the build-time `<I18nProvider>` wrap, and the dist layout — is documented in [Internationalization → URL strategies](/docs/i18n/url-strategies).
|
|
912
|
+
|
|
913
|
+
`loaderData` is the PAGE loader's result. Because `meta` runs server-side after the loader, the SSR'd `<head>` is already correct on first paint — no client-side title patching, no flash.
|
|
914
|
+
|
|
915
|
+
## Examples
|
|
916
|
+
|
|
917
|
+
### Authenticated dashboard with cookie-driven loader
|
|
918
|
+
|
|
919
|
+
```tsx
|
|
920
|
+
// src/pages/dashboard/index.tsx
|
|
921
|
+
import { useLoaderData } from '@voltro/web'
|
|
922
|
+
|
|
923
|
+
export const renderMode = 'ssr' as const
|
|
924
|
+
|
|
925
|
+
export const loader = async ({ headers }) => {
|
|
926
|
+
const cookieHeader = headers.cookie ?? ''
|
|
927
|
+
const me = await fetch(`${INTERNAL_API}/auth/me`, { headers: { cookie: cookieHeader } })
|
|
928
|
+
if (!me.ok) throw new RedirectError('/login')
|
|
929
|
+
const user = await me.json()
|
|
930
|
+
return { user }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
export default function Dashboard() {
|
|
934
|
+
const { user } = useLoaderData<{ user: User }>()
|
|
935
|
+
return <h1>Hi {user.name}</h1>
|
|
936
|
+
}
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
`RedirectError` is the framework's way to issue a 303 from a loader. See [Navigation](/docs/routing/navigation) for client-side analog.
|
|
940
|
+
|
|
941
|
+
### SSG with per-post meta
|
|
942
|
+
|
|
943
|
+
`getStaticPaths` has no framework store — it reads its own content source (a CMS client, the filesystem, an API). The loader runs server-side and fetches via `query` (the backend rpc, resolved to its first snapshot):
|
|
944
|
+
|
|
945
|
+
```tsx
|
|
946
|
+
// src/pages/blog/[slug].tsx
|
|
947
|
+
import { listPostSlugs, type Post } from '../../content/posts'
|
|
948
|
+
|
|
949
|
+
export const renderMode = 'static' as const
|
|
950
|
+
|
|
951
|
+
export const getStaticPaths = async () => {
|
|
952
|
+
const slugs = await listPostSlugs() // your own content source — fs / CMS / API
|
|
953
|
+
return slugs.map((slug) => ({ params: { slug } }))
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export const loader = async ({ params, query }) => {
|
|
957
|
+
// `query` is present only server-side (SSG build / SSR). Resolves the
|
|
958
|
+
// backend query's first snapshot.
|
|
959
|
+
return { post: await query!('posts.getBySlug', { slug: params.slug }) }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
export const meta = ({ loaderData }: { loaderData: { post: Post } }): PageMeta => ({
|
|
963
|
+
title: `${loaderData.post.title} — Blog`,
|
|
964
|
+
description: loaderData.post.excerpt,
|
|
965
|
+
})
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
## Loaders are NOT React hooks
|
|
969
|
+
|
|
970
|
+
They're plain async functions. They can't call `useSubscription`, `useState`, etc. — they run server-side.
|
|
971
|
+
|
|
972
|
+
If you need a reactive query (live updates), use `useSubscription` in the component AFTER hydration; for the initial render's data, use the loader.
|
|
973
|
+
|
|
974
|
+
## Anti-patterns
|
|
975
|
+
|
|
976
|
+
- **Calling `ctx.ai.generate(...)` in a loader without timeouts.** Loaders shouldn't take >2s. For slow data, render a Suspense fallback + `useSubscription` after hydration.
|
|
977
|
+
- **Loaders that mutate state.** Loaders are reads — they're cached, retried, run at build time. Use mutations for writes.
|
|
978
|
+
- **Hardcoding env-only secrets in `meta` tags.** `meta` ships to the client. Public meta only.
|
|
979
|
+
|
|
980
|
+
## Where to read next
|
|
981
|
+
|
|
982
|
+
- [Navigation](/docs/routing/navigation) — client-side routing, prefetch, Link
|
|
983
|
+
- [Render modes](/docs/routing/render-modes) — which mode means what for loaders
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
---
|
|
988
|
+
|
|
989
|
+
<!-- source: en/routing/navigation.md -->
|
|
990
|
+
## Navigation
|
|
991
|
+
|
|
992
|
+
_Link, useNavigate, prefetch on hover, programmatic redirects, and the external/hash escape hatches._
|
|
993
|
+
|
|
994
|
+
Voltro's router is client-side after first paint. Links update the URL via `history.pushState` + re-render the matching page, without a full reload. Loader data prefetches on hover so the next page is ready by the time the user clicks.
|
|
995
|
+
|
|
996
|
+
## Typed URLs — the `routes` builder
|
|
997
|
+
|
|
998
|
+
`<Link to=…>` does not take a bare string. Its `to` prop is a **branded
|
|
999
|
+
`VoltroUrl`**, minted only by the app's generated `routes` builder or by
|
|
1000
|
+
`externalUrl()`. This makes a typo or a link to a route that doesn't exist
|
|
1001
|
+
a compile error instead of a dead link at runtime.
|
|
1002
|
+
|
|
1003
|
+
The codegen writes a `routes` builder from your `src/pages/**` tree. Call
|
|
1004
|
+
the entry for a pattern with its params to get a typed URL:
|
|
1005
|
+
|
|
1006
|
+
```tsx
|
|
1007
|
+
import { Link } from '@voltro/web'
|
|
1008
|
+
import { routes } from './.framework/routes' // generated by `voltro dev`
|
|
1009
|
+
|
|
1010
|
+
<Link to={routes['/notes/[id]']({ id: '42' })}>Open note 42</Link>
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
- `routes['/pattern'](params)` → `VoltroRouteUrl`. Missing/extra params are a type error.
|
|
1014
|
+
- `withQuery(url, { env: 'prod' })` — append a query string, keeps the brand.
|
|
1015
|
+
- `withHash(url, 'section-3')` — append a `#hash`, keeps the brand.
|
|
1016
|
+
- `externalUrl('https://example.com')` — the escape hatch for anything the
|
|
1017
|
+
codegen can't model: cross-origin, `mailto:`, `tel:`, hash-only, or a
|
|
1018
|
+
sibling-app route. A deliberate no-op wrapper so any raw string still has
|
|
1019
|
+
to be opted in at the call site.
|
|
1020
|
+
|
|
1021
|
+
```tsx
|
|
1022
|
+
import { withQuery, withHash, externalUrl } from '@voltro/web'
|
|
1023
|
+
|
|
1024
|
+
<Link to={withQuery(routes['/notes/[id]']({ id: '42' }), { tab: 'comments' })}>Comments</Link>
|
|
1025
|
+
<Link to={withHash(routes['/docs/[[...slug]]']({ slug: ['routing'] }), 'priority')}>Priority</Link>
|
|
1026
|
+
<Link to={externalUrl('mailto:hi@x.com')}>Email us</Link>
|
|
1027
|
+
```
|
|
1028
|
+
|
|
1029
|
+
## `<Link>`
|
|
1030
|
+
|
|
1031
|
+
```tsx
|
|
1032
|
+
import { Link } from '@voltro/web'
|
|
1033
|
+
import { routes } from './.framework/routes'
|
|
1034
|
+
|
|
1035
|
+
<Link to={routes['/notes/[id]']({ id: '42' })}>Open note 42</Link>
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
What it does:
|
|
1039
|
+
|
|
1040
|
+
- Renders an `<a href="/notes/42">` so the link is a real anchor (SEO, right-click → "Open in new tab", screen readers, etc. all just work).
|
|
1041
|
+
- Intercepts plain left-clicks → `history.pushState` + matches the new URL.
|
|
1042
|
+
- Modifier keys + middle-click + external URLs pass through to the browser's native behaviour.
|
|
1043
|
+
|
|
1044
|
+
## Prefetch on hover
|
|
1045
|
+
|
|
1046
|
+
```tsx
|
|
1047
|
+
<Link to={routes['/notes/[id]']({ id: '42' })} prefetch>Open note 42</Link>
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
With `prefetch`, hovering / focusing the link fires the destination's loader in the background. By the time the user actually clicks, `useLoaderData()` resolves immediately on the new page.
|
|
1051
|
+
|
|
1052
|
+
Behaviour:
|
|
1053
|
+
|
|
1054
|
+
- Idempotent — multiple hovers fire one loader call, results are cached.
|
|
1055
|
+
- Cached until used or invalidated — a prefetched result stays in the loader cache and is consumed on the next navigation to that route; it isn't discarded on a timer. It's dropped when the route is invalidated (e.g. an error-boundary reset or a mutation that invalidates the loader's data).
|
|
1056
|
+
- No effect for `static` pages without loaders (nothing to prefetch).
|
|
1057
|
+
|
|
1058
|
+
For "everything on the page is prefetchable", apps usually wire `prefetch` on every internal link by default. Not much downside — loaders are cheap; the wasted ones are typically empty.
|
|
1059
|
+
|
|
1060
|
+
## `useNavigate`
|
|
1061
|
+
|
|
1062
|
+
For programmatic navigation:
|
|
1063
|
+
|
|
1064
|
+
```tsx
|
|
1065
|
+
import { useNavigate } from '@voltro/web'
|
|
1066
|
+
|
|
1067
|
+
const SignOutButton = () => {
|
|
1068
|
+
const navigate = useNavigate()
|
|
1069
|
+
const onSignOut = async () => {
|
|
1070
|
+
await fetch('/auth/signout', { method: 'POST' })
|
|
1071
|
+
navigate('/login')
|
|
1072
|
+
}
|
|
1073
|
+
return <button onClick={onSignOut}>Sign out</button>
|
|
1074
|
+
}
|
|
1075
|
+
```
|
|
1076
|
+
|
|
1077
|
+
Pass a path string. The router updates `window.location.pathname` + renders the new page.
|
|
1078
|
+
|
|
1079
|
+
## External + hash URLs
|
|
1080
|
+
|
|
1081
|
+
Wrap anything the codegen can't model in `externalUrl()`. `<Link>` detects URLs starting with a scheme (`http://`, `https://`, `mailto:`, `tel:`, `#anchor`) at runtime and falls back to plain browser navigation; a route URL from the `routes` builder does SPA navigation.
|
|
1082
|
+
|
|
1083
|
+
```tsx
|
|
1084
|
+
<Link to={externalUrl('https://example.com')}>External</Link> {/* opens normally */}
|
|
1085
|
+
<Link to={externalUrl('mailto:hi@x.com')}>Email</Link> {/* mailto: handler */}
|
|
1086
|
+
<Link to={externalUrl('#section')}>Anchor</Link> {/* in-page scroll */}
|
|
1087
|
+
<Link to={routes['/dashboard']({})}>Internal</Link> {/* SPA nav */}
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
## Active link styling
|
|
1091
|
+
|
|
1092
|
+
```tsx
|
|
1093
|
+
import { Link, useLocation } from '@voltro/web'
|
|
1094
|
+
|
|
1095
|
+
const Nav = () => {
|
|
1096
|
+
const pathname = useLocation()
|
|
1097
|
+
return (
|
|
1098
|
+
<ul>
|
|
1099
|
+
<li><Link to={routes['/']({})} className={pathname === '/' ? 'active' : ''}>Home</Link></li>
|
|
1100
|
+
<li><Link to={routes['/about']({})} className={pathname === '/about' ? 'active' : ''}>About</Link></li>
|
|
1101
|
+
</ul>
|
|
1102
|
+
)
|
|
1103
|
+
}
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
For "active if URL starts with prefix" (parent nav highlighting):
|
|
1107
|
+
|
|
1108
|
+
```tsx
|
|
1109
|
+
className={pathname.startsWith('/dashboard') ? 'active' : ''}
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
Compose this into your own `NavLink` wrapper with `useLocation()` + `<Link>` when you reuse the pattern across many links.
|
|
1113
|
+
|
|
1114
|
+
## Redirects from a loader
|
|
1115
|
+
|
|
1116
|
+
When the loader detects "user should be elsewhere":
|
|
1117
|
+
|
|
1118
|
+
```ts
|
|
1119
|
+
import { RedirectError } from '@voltro/web'
|
|
1120
|
+
|
|
1121
|
+
export const loader = async ({ headers }) => {
|
|
1122
|
+
if (!signedIn(headers)) throw new RedirectError('/login?from=/dashboard')
|
|
1123
|
+
return { /* … */ }
|
|
1124
|
+
}
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
The framework catches it + emits a 303 with `location: /login?from=/dashboard` on SSR; on a client navigation it runs `navigate(..., { replace: true })` so Back doesn't bounce onto the page that redirected. The default status is 303 (a redirect always lands the browser on a GET of the target); pass `{ status: 307 | 308 }` for a method-preserving redirect. The `redirect()` helper is throwing sugar — `if (!signedIn(headers)) redirect('/login')`.
|
|
1128
|
+
|
|
1129
|
+
For client-side redirects (e.g. after a button click):
|
|
1130
|
+
|
|
1131
|
+
```tsx
|
|
1132
|
+
const onSubmit = async () => {
|
|
1133
|
+
await mutate.run({ /* … */ })
|
|
1134
|
+
navigate('/success')
|
|
1135
|
+
}
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
## Scroll behaviour
|
|
1139
|
+
|
|
1140
|
+
By default, the router scrolls to the top on every push navigation. Override per-link:
|
|
1141
|
+
|
|
1142
|
+
```tsx
|
|
1143
|
+
<Link to={withHash(routes['/long-page']({}), 'section-3')}>Jump to section 3</Link>
|
|
1144
|
+
```
|
|
1145
|
+
|
|
1146
|
+
Hash links scroll to the matching `id`. Setting `<Link to={routes['/foo']({})} replace>` replaces the history entry (no back-button entry).
|
|
1147
|
+
|
|
1148
|
+
### Back/forward scroll restoration
|
|
1149
|
+
|
|
1150
|
+
The router restores the previous scroll position on **back/forward** navigations. It sets `history.scrollRestoration = 'manual'` and owns restoration itself, saving each entry's scroll offset before you leave it and re-applying it (after the target route paints) when you pop back. This is automatic — no setup. Because the router restores after the loader-gated target paints, the offset lands on the right content even for a page that's still fetching when you click Back.
|
|
1151
|
+
|
|
1152
|
+
Push/replace navigations still scroll to top (or to the hash target); only back/forward restores.
|
|
1153
|
+
|
|
1154
|
+
## Blocking navigation (unsaved changes)
|
|
1155
|
+
|
|
1156
|
+
`useBlocker` holds a pending navigation so you can prompt before the user leaves — the unsaved-changes guard.
|
|
1157
|
+
|
|
1158
|
+
```tsx
|
|
1159
|
+
import { useBlocker } from '@voltro/web'
|
|
1160
|
+
|
|
1161
|
+
function EditForm() {
|
|
1162
|
+
const [dirty, setDirty] = useState(false)
|
|
1163
|
+
const blocker = useBlocker(dirty) // block while the form has unsaved edits
|
|
1164
|
+
|
|
1165
|
+
return (
|
|
1166
|
+
<form onChange={() => setDirty(true)}>
|
|
1167
|
+
{/* …fields… */}
|
|
1168
|
+
{blocker.blocked && (
|
|
1169
|
+
<div role="dialog">
|
|
1170
|
+
Discard unsaved changes?
|
|
1171
|
+
<button onClick={blocker.retry}>Discard & leave</button>
|
|
1172
|
+
<button onClick={blocker.reset}>Stay</button>
|
|
1173
|
+
</div>
|
|
1174
|
+
)}
|
|
1175
|
+
</form>
|
|
1176
|
+
)
|
|
1177
|
+
}
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
When `useBlocker`'s argument is `true` (or a predicate returning `true`) and the user tries to leave — a `<Link>` click, an intercepted `<a>`, or an imperative `navigate` — the navigation is **held** and the hook returns `{ blocked: true, to, retry, reset }`:
|
|
1181
|
+
|
|
1182
|
+
- `retry()` — proceed with the held-back navigation.
|
|
1183
|
+
- `reset()` — cancel it and stay on the page.
|
|
1184
|
+
- `to` — where the user was trying to go (render it in the prompt if you like).
|
|
1185
|
+
|
|
1186
|
+
A full-page unload (tab close, reload, typed URL) additionally triggers the browser's native leave prompt while any blocker is active.
|
|
1187
|
+
|
|
1188
|
+
Pass a **predicate** to allow some destinations:
|
|
1189
|
+
|
|
1190
|
+
```tsx
|
|
1191
|
+
// Block everything except an explicit sign-out.
|
|
1192
|
+
const blocker = useBlocker(({ to }) => dirty && to !== '/logout')
|
|
1193
|
+
```
|
|
1194
|
+
|
|
1195
|
+
## Route announcer (accessibility)
|
|
1196
|
+
|
|
1197
|
+
On a full page load a screen reader announces the new page. A client-side SPA navigation swaps the DOM without that announcement — so the router ships a built-in **route announcer**: a visually-hidden `aria-live` region that speaks the new page's title (from the route's `meta`, falling back to the pathname) on every navigation. This is automatic — no setup, nothing to render. Give each route a `meta.title` and the announcement is meaningful:
|
|
1198
|
+
|
|
1199
|
+
```tsx
|
|
1200
|
+
export const meta = () => ({ title: 'Team · Acme' })
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
## History APIs
|
|
1204
|
+
|
|
1205
|
+
`navigate` takes a path string only — `(to: string, opts?: { replace?: boolean })`. There is no numeric history overload:
|
|
1206
|
+
|
|
1207
|
+
```ts
|
|
1208
|
+
const navigate = useNavigate()
|
|
1209
|
+
navigate('/foo') // push
|
|
1210
|
+
navigate('/foo', { replace: true }) // replace the current entry
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
For history traversal, reach for the browser API directly:
|
|
1214
|
+
|
|
1215
|
+
```ts
|
|
1216
|
+
window.history.back() // back
|
|
1217
|
+
window.history.forward() // forward
|
|
1218
|
+
```
|
|
1219
|
+
|
|
1220
|
+
## Reading + writing search params
|
|
1221
|
+
|
|
1222
|
+
Read the query string with `useSearchParams()` — a plain `URLSearchParams`. It's SSR-aware (on the server it reads the request URL; on the client, `window.location.search`):
|
|
1223
|
+
|
|
1224
|
+
```tsx
|
|
1225
|
+
import { useSearchParams } from '@voltro/web'
|
|
1226
|
+
|
|
1227
|
+
const tab = useSearchParams().get('tab') ?? 'overview'
|
|
1228
|
+
```
|
|
1229
|
+
|
|
1230
|
+
Write it with `useSetSearchParams()` — the setter updates the query string on the current pathname (via `navigate`), so the URL changes **and** every reader re-renders immediately:
|
|
1231
|
+
|
|
1232
|
+
```tsx
|
|
1233
|
+
import { useSearchParams, useSetSearchParams } from '@voltro/web'
|
|
1234
|
+
|
|
1235
|
+
function Tabs() {
|
|
1236
|
+
const tab = useSearchParams().get('tab') ?? 'overview'
|
|
1237
|
+
const setParams = useSetSearchParams()
|
|
1238
|
+
return (
|
|
1239
|
+
<nav>
|
|
1240
|
+
<button onClick={() => setParams({ tab: 'overview' })}>Overview</button>
|
|
1241
|
+
<button onClick={() => setParams({ tab: 'members' })}>Members</button>
|
|
1242
|
+
</nav>
|
|
1243
|
+
)
|
|
1244
|
+
}
|
|
1245
|
+
```
|
|
1246
|
+
|
|
1247
|
+
The setter takes either an object / `URLSearchParams`, or an updater that receives the current params:
|
|
1248
|
+
|
|
1249
|
+
```ts
|
|
1250
|
+
const setParams = useSetSearchParams()
|
|
1251
|
+
setParams({ tab: 'members' }) // set the whole query
|
|
1252
|
+
setParams((p) => { p.set('page', '2'); return p }) // patch one param
|
|
1253
|
+
setParams({}) // clear the query string
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
Search-param writes default to a history **replace** (a filter/tab tweak shouldn't stack a Back entry per keystroke). Pass `{ push: true }` for a distinct history entry, or `{ scroll: false }` to keep the scroll position:
|
|
1257
|
+
|
|
1258
|
+
```ts
|
|
1259
|
+
setParams({ page: '2' }, { push: true })
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
During SSR there is no history to write — read `useSearchParams()` off the request URL for the first paint and call `useSetSearchParams()` on the client after hydration.
|
|
1263
|
+
|
|
1264
|
+
## Prefetching programmatically
|
|
1265
|
+
|
|
1266
|
+
```tsx
|
|
1267
|
+
import { usePrefetch } from '@voltro/web'
|
|
1268
|
+
|
|
1269
|
+
const Card = ({ id }) => {
|
|
1270
|
+
const prefetch = usePrefetch()
|
|
1271
|
+
return (
|
|
1272
|
+
<article onMouseEnter={() => prefetch(`/notes/${id}`)}>
|
|
1273
|
+
{/* …card body, no Link inside */}
|
|
1274
|
+
</article>
|
|
1275
|
+
)
|
|
1276
|
+
}
|
|
1277
|
+
```
|
|
1278
|
+
|
|
1279
|
+
Useful when the prefetch trigger isn't a `<Link>` (e.g. an entire card area, where the inner link is buried).
|
|
1280
|
+
|
|
1281
|
+
## Anti-patterns
|
|
1282
|
+
|
|
1283
|
+
- **`<a href>` for internal queries.** Falls through the SPA — full reload. Use `<Link>` instead.
|
|
1284
|
+
- **`window.location.href = '/foo'`.** Same — full reload. Use `useNavigate()`.
|
|
1285
|
+
- **`prefetch` on every link blindly.** For cookie-gated loaders that hit DB, hovering 50 nav items can pile up 50 DB queries. Use `prefetch` for high-confidence destinations only.
|
|
1286
|
+
|
|
1287
|
+
## Where to read next
|
|
1288
|
+
|
|
1289
|
+
- [Loaders & meta](/docs/routing/loaders-and-meta) — what prefetch warms up
|
|
1290
|
+
- [Islands](/docs/routing/islands) — for pages where most of the JS is stripped
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
---
|
|
1295
|
+
|
|
1296
|
+
<!-- source: en/routing/islands.md -->
|
|
1297
|
+
## Islands
|
|
1298
|
+
|
|
1299
|
+
_interactive: 'islands' — ship pure HTML with selectively-hydrated interactive components._
|
|
1300
|
+
|
|
1301
|
+
The **islands** model: serve the page as pure HTML, then hydrate only the bits that need interactivity. The rest of the page is just text + CSS — no React runtime, no JS bundle, no hydration cost.
|
|
1302
|
+
|
|
1303
|
+
Voltro implements islands per-page via the `interactive` export:
|
|
1304
|
+
|
|
1305
|
+
```tsx
|
|
1306
|
+
export const renderMode = 'static' as const
|
|
1307
|
+
export const interactive = 'islands' as const
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
With `interactive: 'islands'`, the framework strips the page's React runtime from the HTML, but loads `*.island.tsx` files as separate chunks + hydrates them in place.
|
|
1311
|
+
|
|
1312
|
+
## When to use islands
|
|
1313
|
+
|
|
1314
|
+
- **Marketing pages** with one interactive widget (a pricing toggle, a code playground).
|
|
1315
|
+
- **Docs** that are mostly text but have a search modal + theme toggle.
|
|
1316
|
+
- **Blog posts** with an embedded poll or comment widget.
|
|
1317
|
+
|
|
1318
|
+
The split lets you ship near-zero JS for the body + JS only for the islands. Lighthouse scores stay high; React's overhead applies only to the interactive parts.
|
|
1319
|
+
|
|
1320
|
+
## Writing an island
|
|
1321
|
+
|
|
1322
|
+
Wrap a component with `island(Component, { name, hydrate })` and default-export the result. The plain component is NOT enough — without the `island()` call the component is never registered, and at hydration time the runtime logs `island "…" not registered`.
|
|
1323
|
+
|
|
1324
|
+
```tsx
|
|
1325
|
+
// src/components/LikeButton.island.tsx
|
|
1326
|
+
import { island } from '@voltro/web'
|
|
1327
|
+
import { useState } from 'react'
|
|
1328
|
+
|
|
1329
|
+
const LikeButton = ({ initial }: { initial: number }) => {
|
|
1330
|
+
const [count, setCount] = useState(initial)
|
|
1331
|
+
return (
|
|
1332
|
+
<button onClick={() => setCount((n) => n + 1)}>
|
|
1333
|
+
❤ {count}
|
|
1334
|
+
</button>
|
|
1335
|
+
)
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
export default island(LikeButton, { name: 'LikeButton', hydrate: 'visible' })
|
|
1339
|
+
```
|
|
1340
|
+
|
|
1341
|
+
- **`name`** — the stable id under which the component is registered. Must be unique within the app. Both the SSR and the client bundle import the file, so the same `island()` call runs on both sides and registers the component in each.
|
|
1342
|
+
- **`hydrate`** — when the client runtime should hydrate this island (defaults to `'visible'`). The five strategies are in the table below.
|
|
1343
|
+
|
|
1344
|
+
Use it in a page:
|
|
1345
|
+
|
|
1346
|
+
```tsx
|
|
1347
|
+
// src/pages/blog/[slug].tsx
|
|
1348
|
+
import LikeButton from '../../components/LikeButton.island'
|
|
1349
|
+
|
|
1350
|
+
export const renderMode = 'static' as const
|
|
1351
|
+
export const interactive = 'islands' as const
|
|
1352
|
+
|
|
1353
|
+
export default function Post() {
|
|
1354
|
+
return (
|
|
1355
|
+
<article>
|
|
1356
|
+
<h1>Post title</h1>
|
|
1357
|
+
<p>…body content…</p>
|
|
1358
|
+
<LikeButton initial={42} />
|
|
1359
|
+
</article>
|
|
1360
|
+
)
|
|
1361
|
+
}
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
What happens at build:
|
|
1365
|
+
|
|
1366
|
+
1. The page is server-rendered to HTML. The `island()` wrapper emits a marker `<div>` carrying the name, props, and hydrate strategy:
|
|
1367
|
+
```html
|
|
1368
|
+
<div data-voltro-island data-island-name="LikeButton"
|
|
1369
|
+
data-island-hydrate="visible" data-island-props='{"initial":42}'>
|
|
1370
|
+
<button>❤ 42</button>
|
|
1371
|
+
</div>
|
|
1372
|
+
```
|
|
1373
|
+
2. The island bundles into the client chunk.
|
|
1374
|
+
3. The client runtime scans for `[data-voltro-island]` markers, looks each name up in its registry, and hydrates that `<div>` per its `data-island-hydrate` strategy.
|
|
1375
|
+
|
|
1376
|
+
The rest of the page stays as inert HTML.
|
|
1377
|
+
|
|
1378
|
+
## Hydrate strategies
|
|
1379
|
+
|
|
1380
|
+
Each island declares WHEN it hydrates via the `hydrate` option (default `'visible'`):
|
|
1381
|
+
|
|
1382
|
+
| Strategy | When the island hydrates | Use for |
|
|
1383
|
+
|---|---|---|
|
|
1384
|
+
| `load` | Immediately, as soon as the client runtime mounts | Above-the-fold widgets users touch within the first second — search box, primary CTA. |
|
|
1385
|
+
| `idle` | When the browser is idle (`requestIdleCallback`, `setTimeout` fallback) | Important widgets that don't need instant interactivity — analytics, secondary nav. |
|
|
1386
|
+
| `visible` (default) | When the element scrolls into the viewport (IntersectionObserver) | Anything below the fold — comment box, related-articles carousel. |
|
|
1387
|
+
| `interaction` | On the first pointer / keyboard event on the element | Heavy widgets users *might* touch — embedded playground, deep tree viewer. Defers cost until commitment. |
|
|
1388
|
+
| `never` | Never — the server-rendered HTML stays inert | Server-only displays that never change after SSR (a build-time status badge). |
|
|
1389
|
+
|
|
1390
|
+
Mix freely inside one page: a `load` search box, a `visible` comment widget, and a `never` build banner can all coexist.
|
|
1391
|
+
|
|
1392
|
+
## Bundle savings
|
|
1393
|
+
|
|
1394
|
+
For a typical Voltro page:
|
|
1395
|
+
|
|
1396
|
+
| Mode | JS shipped |
|
|
1397
|
+
|---|---|
|
|
1398
|
+
| `interactive: 'full'` | Full React + page bundle (~80-150 KB gzipped) |
|
|
1399
|
+
| `interactive: 'islands'` | Island boot loader + island chunks (typically 10-30 KB gzipped) |
|
|
1400
|
+
| `interactive: 'none'` | Zero |
|
|
1401
|
+
|
|
1402
|
+
For pages where the body never moves, the savings are dramatic.
|
|
1403
|
+
|
|
1404
|
+
## Island boundaries
|
|
1405
|
+
|
|
1406
|
+
The island component owns its sub-tree's interactivity. Inside an island, you can:
|
|
1407
|
+
|
|
1408
|
+
- `useState`, `useEffect`, every React hook
|
|
1409
|
+
- Import + use any other component
|
|
1410
|
+
- Render JSX freely
|
|
1411
|
+
|
|
1412
|
+
What you CAN'T do:
|
|
1413
|
+
|
|
1414
|
+
- Make the *parent page* interactive from inside. The island can't trigger a page-level re-render.
|
|
1415
|
+
- Read from React Context defined in the page. Each island has its own React root.
|
|
1416
|
+
- Share state across islands directly. Use the URL, `localStorage`, or a custom message channel.
|
|
1417
|
+
|
|
1418
|
+
Each island is independent — there is no shared React root across islands. To coordinate, use the URL, `localStorage`, or a custom message channel.
|
|
1419
|
+
|
|
1420
|
+
## Props serialisation
|
|
1421
|
+
|
|
1422
|
+
Props passed to an island must be **JSON-serialisable**. The framework serialises them into the marker's `data-island-props` attribute + hydrates with the same values.
|
|
1423
|
+
|
|
1424
|
+
OK:
|
|
1425
|
+
|
|
1426
|
+
```tsx
|
|
1427
|
+
<LikeButton initial={42} kind="heart" tags={['blog']} />
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
NOT OK:
|
|
1431
|
+
|
|
1432
|
+
```tsx
|
|
1433
|
+
<LikeButton onClick={() => …} /> // functions can't serialise
|
|
1434
|
+
<LikeButton date={new Date()} /> // Date → string; use ISO + parse inside
|
|
1435
|
+
<LikeButton ref={someRef} /> // refs are component-local
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
If you need to pass a function reference, define it INSIDE the island.
|
|
1439
|
+
|
|
1440
|
+
## When NOT to use islands
|
|
1441
|
+
|
|
1442
|
+
- **Whole page is interactive.** Use `interactive: 'full'` — you'd just be adding the island boot overhead for no benefit.
|
|
1443
|
+
- **Islands that share state.** Each island is its own root — two islands talking is painful. Coordinate via the URL, `localStorage`, or a message channel.
|
|
1444
|
+
- **Islands that hydrate immediately and dominate the page weight.** If the island is the whole page minus a header, just go `interactive: 'full'`.
|
|
1445
|
+
|
|
1446
|
+
## Combining with render modes
|
|
1447
|
+
|
|
1448
|
+
| `renderMode` × `interactive` | Use case |
|
|
1449
|
+
|---|---|
|
|
1450
|
+
| `static` + `islands` | Marketing landing with a pricing toggle |
|
|
1451
|
+
| `static` + `none` | Pure-content blog posts |
|
|
1452
|
+
| `static` + `full` | SPA-like docs sites |
|
|
1453
|
+
| `ssr` + `islands` | Personalised pages with a few interactive widgets |
|
|
1454
|
+
| `isr` + `islands` | High-traffic listings with a "like" button |
|
|
1455
|
+
|
|
1456
|
+
## Inspecting
|
|
1457
|
+
|
|
1458
|
+
The build pipeline reports which chunks are islands:
|
|
1459
|
+
|
|
1460
|
+
```
|
|
1461
|
+
[voltro build] vite build…
|
|
1462
|
+
chunks emitted:
|
|
1463
|
+
main.abc.js 145 KB ← page runtime (full pages only)
|
|
1464
|
+
island-LikeButton.def.js 3.4 KB ← per-island bundle
|
|
1465
|
+
island-Search.ghi.js 8.1 KB
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
Inspect dashboard's "Bundles" panel shows the per-island size + hydration timings.
|
|
1469
|
+
|
|
1470
|
+
## Anti-patterns
|
|
1471
|
+
|
|
1472
|
+
- **Wrapping everything in one big island.** Defeats the purpose — you've just rebuilt full hydration with extra steps.
|
|
1473
|
+
- **Passing 100 KB of JSON as island props.** The serialised payload ends up in the HTML — pretty quickly an island's "props" cost dwarfs the saved bundle.
|
|
1474
|
+
- **Calling `useLoaderData` inside an island.** Loaders run for the PAGE, not islands. Islands receive props from the page; the page reads loader data.
|
|
1475
|
+
|
|
1476
|
+
## Where to read next
|
|
1477
|
+
|
|
1478
|
+
- [Render modes](/docs/routing/render-modes) — pairs with `interactive`
|
|
1479
|
+
- [Navigation](/docs/routing/navigation) — Link + prefetch work the same on islands pages
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
---
|
|
1484
|
+
|
|
1485
|
+
<!-- source: en/routing/styling.md -->
|
|
1486
|
+
## Styling (Tailwind v4)
|
|
1487
|
+
|
|
1488
|
+
_Tailwind v4 is auto-loaded in every web app's Vite pipeline. The mandatory @source glob, @theme design tokens, and the two silent-failure gotchas (the framework root is .framework/, not src/; a second @theme after @layer is dropped)._
|
|
1489
|
+
|
|
1490
|
+
Every Voltro web app's Vite pipeline auto-loads `@tailwindcss/vite`. Apps that
|
|
1491
|
+
don't use Tailwind pay no runtime cost (the plugin emits nothing when no
|
|
1492
|
+
`@import "tailwindcss"` appears in any CSS file).
|
|
1493
|
+
|
|
1494
|
+
To use it:
|
|
1495
|
+
|
|
1496
|
+
```css
|
|
1497
|
+
/* src/globals.css */
|
|
1498
|
+
@import "tailwindcss";
|
|
1499
|
+
|
|
1500
|
+
/* IMPORTANT: the framework's Vite root is `<app>/.framework/`, not
|
|
1501
|
+
`<app>/src/`. Without @source, Tailwind's content scanner misses every .tsx
|
|
1502
|
+
file under src/ — you get an empty `@layer utilities` and zero applied
|
|
1503
|
+
styles. The path is relative to THIS css file. */
|
|
1504
|
+
@source "./**/*.{tsx,ts,jsx,js}";
|
|
1505
|
+
|
|
1506
|
+
@theme {
|
|
1507
|
+
--color-background: #0a0a0a;
|
|
1508
|
+
--color-foreground: #fafafa;
|
|
1509
|
+
--radius: 0.5rem;
|
|
1510
|
+
}
|
|
1511
|
+
```
|
|
1512
|
+
|
|
1513
|
+
```tsx
|
|
1514
|
+
// src/pages/layout.tsx
|
|
1515
|
+
import '../globals.css'
|
|
1516
|
+
```
|
|
1517
|
+
|
|
1518
|
+
Tailwind v4's `@theme` block doubles as the framework's design-tokens surface —
|
|
1519
|
+
define your colours / radii / fonts there once and they become available as
|
|
1520
|
+
`bg-background`, `text-foreground`, etc. The shadcn convention works directly on
|
|
1521
|
+
top: copy a component's source, the classes resolve.
|
|
1522
|
+
|
|
1523
|
+
> **Gotcha:** keep ALL `@theme` blocks ABOVE any `@layer` rules in the same
|
|
1524
|
+
> stylesheet. A second `@theme` block placed AFTER `@layer base` is silently
|
|
1525
|
+
> dropped by Tailwind v4 — none of its tokens are emitted.
|
|
1526
|
+
|
|
1527
|
+
## Using `@voltro/ui-shadcn` — the kit `@source` is mandatory
|
|
1528
|
+
|
|
1529
|
+
If your app imports `@voltro/ui-shadcn/tokens.css`, you MUST declare a SECOND
|
|
1530
|
+
`@source` pointing at the kit's source — otherwise every class that exists ONLY
|
|
1531
|
+
inside a kit component (animations like `motion-safe:animate-mesh-drift-a`,
|
|
1532
|
+
`motion-safe:animate-twinkle`, kit-internal prose variants) is silently dropped
|
|
1533
|
+
from the generated CSS. The components mount and the keyframes register, but the
|
|
1534
|
+
`animate-*` utility classes never resolve — no warning is emitted.
|
|
1535
|
+
|
|
1536
|
+
```css
|
|
1537
|
+
/* src/globals.css — app uses kit compositions */
|
|
1538
|
+
@import "@voltro/ui-shadcn/tokens.css";
|
|
1539
|
+
|
|
1540
|
+
@source "./**/*.{tsx,ts,jsx,js}";
|
|
1541
|
+
@source "../node_modules/@voltro/ui-shadcn/src/**/*.{tsx,ts,jsx,js}";
|
|
1542
|
+
```
|
|
1543
|
+
|
|
1544
|
+
Why: the kit's `tokens.css` ships its own `@source "./**/*"`, but `@source` paths
|
|
1545
|
+
resolve relative to the IMPORTING css file — so once your `globals.css` imports
|
|
1546
|
+
the kit, the kit's `./**/*` glob expands to YOUR app's src, not the kit's. The
|
|
1547
|
+
kit's own components are then never scanned. Declaring the explicit kit path
|
|
1548
|
+
closes the gap — the published package ships its `src/` precisely so this glob
|
|
1549
|
+
matches (workspace link and npm install alike).
|
|
1550
|
+
|
|
1551
|
+
How to verify: `curl -sS http://localhost:<port>/@fs/<abs>/src/globals.css | grep
|
|
1552
|
+
animate-` should list every kit animation utility you use. If a class is missing,
|
|
1553
|
+
the `@source` is misconfigured.
|