@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,2062 @@
|
|
|
1
|
+
# templates.appShells
|
|
2
|
+
|
|
3
|
+
> A marketing landing page — hero, features, CTA. Static-rendered with zero JS on the wire by default.
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
<!-- source: en/templates/landing.md -->
|
|
10
|
+
## Landing
|
|
11
|
+
|
|
12
|
+
_A marketing landing page — hero, features, CTA. Static-rendered with zero JS on the wire by default._
|
|
13
|
+
|
|
14
|
+
A marketing landing page. The page exports `renderMode = 'static'` + `interactive = 'none'`, so `voltro build` pre-renders it to HTML and `voltro start` serves the file directly — zero framework JS on the wire. Template id: **`frontend-landing`**.
|
|
15
|
+
|
|
16
|
+
It ships plain JSX (a hero, a features list, a CTA) you replace with your own copy — no design-system dependency to fight. When you need a contact form or sign-up flow, switch `interactive: 'islands'` on the page and mark the interactive component with a `*.island.tsx` suffix so only that bundle ships.
|
|
17
|
+
|
|
18
|
+
## Scaffold
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
voltro create-project acme --web=frontend-landing
|
|
22
|
+
# or onto an existing project
|
|
23
|
+
voltro add-app marketing --template=frontend-landing --to acme
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What ships
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
30
|
+
├── app.config.ts # type:web, port:<allocated>
|
|
31
|
+
├── package.json
|
|
32
|
+
├── tsconfig.json
|
|
33
|
+
└── src/
|
|
34
|
+
├── globals.css
|
|
35
|
+
└── pages/
|
|
36
|
+
├── layout.tsx # imports globals.css, renders {children}
|
|
37
|
+
└── index.tsx # the landing page (hero · features · CTA)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## The page
|
|
41
|
+
|
|
42
|
+
`src/pages/index.tsx` is plain JSX with the two static-render exports:
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
export const renderMode = 'static' as const // pre-render at build time
|
|
46
|
+
export const interactive = 'none' as const // strip ALL framework JS — plain HTML + CSS
|
|
47
|
+
|
|
48
|
+
export default function Index() {
|
|
49
|
+
return (
|
|
50
|
+
<main>
|
|
51
|
+
<section>
|
|
52
|
+
<h1>{/* your brand */}</h1>
|
|
53
|
+
<p>Replace this hero copy with your value prop.</p>
|
|
54
|
+
<a href="#features">Learn more →</a>
|
|
55
|
+
</section>
|
|
56
|
+
<section id="features">
|
|
57
|
+
<h2>Features</h2>
|
|
58
|
+
<ul>{/* feature bullets */}</ul>
|
|
59
|
+
</section>
|
|
60
|
+
<section>
|
|
61
|
+
<h2>Ready to start?</h2>
|
|
62
|
+
<p>Edit <code>src/pages/index.tsx</code> to make it yours.</p>
|
|
63
|
+
</section>
|
|
64
|
+
</main>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`interactive: 'none'` means the framework strips every `<script type="module">` from the rendered HTML — the page ships as content + CSS only. Best perf for a pure-content marketing page.
|
|
70
|
+
|
|
71
|
+
## Adding an interactive piece
|
|
72
|
+
|
|
73
|
+
A contact form, a newsletter signup, a theme toggle — anything that needs JS — goes in an island so only that bundle hydrates:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
// src/components/SignupForm.island.tsx
|
|
77
|
+
import { island } from '@voltro/web'
|
|
78
|
+
const SignupForm = () => { /* … */ }
|
|
79
|
+
export default island(SignupForm, { name: 'SignupForm', hydrate: 'visible' })
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
// src/pages/index.tsx
|
|
84
|
+
export const interactive = 'islands' as const // was 'none'
|
|
85
|
+
import SignupForm from '../components/SignupForm.island'
|
|
86
|
+
// … render <SignupForm /> somewhere in the page …
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The surrounding HTML stays static; only the island hydrates.
|
|
90
|
+
|
|
91
|
+
## Styling
|
|
92
|
+
|
|
93
|
+
`globals.css` is yours. Add `@import "tailwindcss"` plus the mandatory `@source "./**/*.{tsx,ts,jsx,js}"` glob if you want Tailwind, or `@import "@voltro/ui-shadcn/tokens.css"` (plus the kit `@source`) to pull in the design tokens and compose shadcn-style components on top.
|
|
94
|
+
|
|
95
|
+
## What it doesn't ship
|
|
96
|
+
|
|
97
|
+
- **A sign-in form / auth.** Wire it yourself, or pair the page with an `api` and the framework's session helpers.
|
|
98
|
+
- **Live data / subscriptions.** Marketing pages are static. Pair with an api template if you need a live stat.
|
|
99
|
+
|
|
100
|
+
## Pairs well with
|
|
101
|
+
|
|
102
|
+
- [`frontend-docs`](/docs/templates/docs) — link "Docs" from the landing's nav.
|
|
103
|
+
- Any api template — pair the marketing site with a backend in the same project.
|
|
104
|
+
|
|
105
|
+
## Anti-patterns
|
|
106
|
+
|
|
107
|
+
- **Hydrating the whole page when only one widget is interactive.** Keep `interactive: 'none'` and wrap the one interactive bit in an `island()` (then set `'islands'`) — don't flip the whole page to `'full'`.
|
|
108
|
+
- **Leaving `renderMode` unset.** The default is `'static'` already, but the explicit pair (`'static'` + `'none'`) is what makes this page ship zero JS — keep it.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
<!-- source: en/templates/blank.md -->
|
|
115
|
+
## Blank
|
|
116
|
+
|
|
117
|
+
_Empty React + Vite + Tailwind shell. Start here when you want custom UI without an opinion getting in the way._
|
|
118
|
+
|
|
119
|
+
The minimal web app — React + Vite + the framework's wiring + nothing else. Use it when you want full design control without inheriting the design tokens, layouts, or compositions other templates ship. Template id: **`frontend-blank`**.
|
|
120
|
+
|
|
121
|
+
## Scaffold
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
voltro create-project acme --web=frontend-blank
|
|
125
|
+
voltro add-app marketing --template=frontend-blank --to acme
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## What ships
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
132
|
+
├── app.config.ts # type:web, port:<allocated>
|
|
133
|
+
├── package.json
|
|
134
|
+
├── tsconfig.json
|
|
135
|
+
└── src/
|
|
136
|
+
└── pages/
|
|
137
|
+
├── layout.tsx # just renders {children}
|
|
138
|
+
└── index.tsx # an index page
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
That's it. No `@voltro/ui-shadcn` dependency, no design tokens, no `globals.css`. Add `@import "tailwindcss"` in a `src/globals.css` (and the mandatory `@source` glob) yourself when you want Tailwind.
|
|
142
|
+
|
|
143
|
+
## When to use blank vs. landing
|
|
144
|
+
|
|
145
|
+
| You want… | Pick |
|
|
146
|
+
|---|---|
|
|
147
|
+
| A marketing landing surface | `frontend-landing` |
|
|
148
|
+
| A documentation site | `frontend-docs` |
|
|
149
|
+
| To bring your own brand / UI from scratch | `frontend-blank` |
|
|
150
|
+
|
|
151
|
+
If you're going to override every default + replace every composition, scaffold `frontend-blank` instead of fighting `frontend-landing`'s defaults.
|
|
152
|
+
|
|
153
|
+
## When to NOT use blank
|
|
154
|
+
|
|
155
|
+
If your final product is going to be a:
|
|
156
|
+
|
|
157
|
+
- Marketing landing → scaffold [`frontend-landing`](/docs/templates/landing).
|
|
158
|
+
- Docs site → scaffold [`frontend-docs`](/docs/templates/docs).
|
|
159
|
+
|
|
160
|
+
`frontend-blank` is the right pick when neither opinionated frontend template matches your shape, AND you want to keep the dependency footprint minimal.
|
|
161
|
+
|
|
162
|
+
## What you have to do yourself
|
|
163
|
+
|
|
164
|
+
- **Layout shell** — `layout.tsx` just renders `{children}`. Add a header, footer, etc.
|
|
165
|
+
- **Styling** — no `globals.css` ships. Add `@import "tailwindcss"` + the `@source` glob yourself, or bring any other CSS approach.
|
|
166
|
+
- **Component library** — bring your own or `pnpm add @voltro/ui-shadcn` later for the kit's components.
|
|
167
|
+
- **Theme toggle** — none included. Add via `@voltro/ui-shadcn`'s `ThemeToggle` or roll your own.
|
|
168
|
+
|
|
169
|
+
## Pairs well with
|
|
170
|
+
|
|
171
|
+
- Any api template — `frontend-blank` doesn't presuppose one. The wiring is the same as the other web templates.
|
|
172
|
+
- [`@voltro/ui-shadcn`](/docs/routing/styling) — the kit can be added incrementally. Import primitives one at a time (the styling page covers the mandatory Tailwind wiring).
|
|
173
|
+
|
|
174
|
+
## Anti-patterns
|
|
175
|
+
|
|
176
|
+
- **Starting with `frontend-blank` to "save bundle size" then importing every kit primitive.** If you end up wanting most of the kit, scaffold `frontend-landing` — the styling is already set up.
|
|
177
|
+
- **Treating `frontend-blank` as the "canonical" template.** It's the LEAST opinionated. The other frontend templates are built on the same primitives plus a layer of curated design.
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
<!-- source: en/templates/docs.md -->
|
|
184
|
+
## Docs
|
|
185
|
+
|
|
186
|
+
_A documentation site with a catch-all docs router and build-time static pre-rendering of every page via getStaticPaths._
|
|
187
|
+
|
|
188
|
+
A documentation-site starter. `src/pages/docs/[...slug].tsx` is a catch-all router; `getStaticPaths` enumerates every doc URL at build time so `voltro build` pre-renders them all into a static site you can deploy anywhere. Template id: **`frontend-docs`**.
|
|
189
|
+
|
|
190
|
+
The starter ships a tiny in-file `DOCS` array as stub content. Replace it with a real loader (a markdown reader, a CMS query, a database fetch) — the page component's contract stays the same.
|
|
191
|
+
|
|
192
|
+
## Scaffold
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
voltro create-project mydocs --web=frontend-docs
|
|
196
|
+
# or
|
|
197
|
+
voltro add-app docs --template=frontend-docs --to acme
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
## What ships
|
|
201
|
+
|
|
202
|
+
```text
|
|
203
|
+
apps/acme/docs/ # dir named by the app, not the template
|
|
204
|
+
├── app.config.ts # type:web, port:<allocated>
|
|
205
|
+
├── package.json
|
|
206
|
+
├── tsconfig.json
|
|
207
|
+
└── src/
|
|
208
|
+
├── globals.css
|
|
209
|
+
└── pages/
|
|
210
|
+
├── layout.tsx # the shell wrapping every page
|
|
211
|
+
├── index.tsx # / landing page
|
|
212
|
+
└── docs/
|
|
213
|
+
└── [...slug].tsx # /docs/* catch-all renderer
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## The catch-all renderer
|
|
217
|
+
|
|
218
|
+
`src/pages/docs/[...slug].tsx` is the whole engine. It declares:
|
|
219
|
+
|
|
220
|
+
```tsx
|
|
221
|
+
export const renderMode = 'static' as const // pre-render at build
|
|
222
|
+
export const interactive = 'none' as const // ship zero JS — pure content
|
|
223
|
+
|
|
224
|
+
// Replace this stub with your own loader (markdown folder, CMS, DB).
|
|
225
|
+
const DOCS = [
|
|
226
|
+
{ slug: 'intro/getting-started', title: 'Getting started', body: '…' },
|
|
227
|
+
{ slug: 'guides/first-page', title: 'Your first page', body: '…' },
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
// getStaticPaths enumerates the URLs to pre-render. The captured slug
|
|
231
|
+
// is the raw `/`-joined path (NOT a string array), so 'intro/getting-started'
|
|
232
|
+
// renders one HTML file at /docs/intro/getting-started.
|
|
233
|
+
export const getStaticPaths = async () =>
|
|
234
|
+
DOCS.map((d) => ({ params: { slug: d.slug } }))
|
|
235
|
+
|
|
236
|
+
export default function DocPage() {
|
|
237
|
+
const { slug } = useParams<{ slug: string }>()
|
|
238
|
+
const doc = DOCS.find((d) => d.slug === slug)
|
|
239
|
+
// … render doc.title + doc.body, or a not-found fallback …
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Every `getStaticPaths` entry becomes one pre-rendered HTML file at build time. Because `interactive: 'none'`, the framework strips the JS bundle from each page — the output is plain content HTML.
|
|
244
|
+
|
|
245
|
+
## Wiring real content
|
|
246
|
+
|
|
247
|
+
The stub `DOCS` array is the swap point. Point the loader at whatever holds your docs:
|
|
248
|
+
|
|
249
|
+
- **Markdown folder** — read `*.md` at build time with a Vite `import.meta.glob` and parse frontmatter; map each file to a `{ slug, title, body }`.
|
|
250
|
+
- **CMS / database** — fetch the doc index in `getStaticPaths`, fetch each page's body in the loader.
|
|
251
|
+
|
|
252
|
+
The component contract (`{ slug }` param → render a doc) doesn't change — only where `DOCS` comes from.
|
|
253
|
+
|
|
254
|
+
## Adding a page
|
|
255
|
+
|
|
256
|
+
With the stub source, a page is a new entry in the `DOCS` array. With a real markdown loader, a page is a new `.md` file the glob picks up. Either way `getStaticPaths` re-enumerates and `voltro build` emits one more HTML file.
|
|
257
|
+
|
|
258
|
+
## Pairs well with
|
|
259
|
+
|
|
260
|
+
- [`frontend-landing`](/docs/templates/landing) — the docs share the marketing site's brand.
|
|
261
|
+
|
|
262
|
+
## Anti-patterns
|
|
263
|
+
|
|
264
|
+
- **Client-side markdown rendering.** Render content during the build / loader pass so it reaches React as pre-rendered HTML. Re-rendering every page client-side is wasteful and hurts search-engine indexing.
|
|
265
|
+
- **Leaving `interactive` unset on content pages.** The starter sets `'none'` deliberately — a docs page that ships no JS loads fastest. Only switch to `'islands'` if you add a search box or theme toggle.
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
<!-- source: en/templates/changelog.md -->
|
|
272
|
+
## Changelog
|
|
273
|
+
|
|
274
|
+
_A changelog / release-notes site — MDX releases rendered to a list and per-release pages with highlighted code, plus an RSS feed, powered by @voltro/changelog._
|
|
275
|
+
|
|
276
|
+
A changelog / release-notes site. Write each release as an `.mdx` file; the template renders a reverse-chronological list, one page per release with syntax-highlighted code, and an RSS feed. Powered by `@voltro/changelog`. Template id: **`changelog`**.
|
|
277
|
+
|
|
278
|
+
## Scaffold
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
voltro create-project myapp --web=changelog
|
|
282
|
+
# or
|
|
283
|
+
voltro add-app changelog --template=changelog --to acme
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## What ships
|
|
287
|
+
|
|
288
|
+
```text
|
|
289
|
+
apps/acme/changelog/ # dir named by the app, not the template
|
|
290
|
+
├── app.config.ts # type:web, port:<allocated>
|
|
291
|
+
├── package.json # build = generate RSS, then voltro build
|
|
292
|
+
├── tsconfig.json
|
|
293
|
+
├── content/
|
|
294
|
+
│ └── releases/
|
|
295
|
+
│ ├── 0.1.0.mdx # one file per release (frontmatter + markdown)
|
|
296
|
+
│ └── 0.2.0.mdx
|
|
297
|
+
├── scripts/
|
|
298
|
+
│ └── generate-rss.mjs # writes public/rss.xml at build time
|
|
299
|
+
└── src/
|
|
300
|
+
├── globals.css
|
|
301
|
+
├── globals.d.ts
|
|
302
|
+
├── lib/
|
|
303
|
+
│ └── releases.ts # loadReleases over the MDX glob
|
|
304
|
+
└── pages/
|
|
305
|
+
├── layout.tsx # BlogLayout shell
|
|
306
|
+
├── index.tsx # the release list
|
|
307
|
+
└── [slug].tsx # one page per release
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## How it works
|
|
311
|
+
|
|
312
|
+
A release is an `.mdx` file with frontmatter. `version` and `releasedAt` are required; everything else is optional:
|
|
313
|
+
|
|
314
|
+
```mdx
|
|
315
|
+
---
|
|
316
|
+
version: 0.2.0
|
|
317
|
+
releasedAt: 2026-06-21
|
|
318
|
+
slug: v0-2-0 # URL segment; defaults to a slugified version
|
|
319
|
+
title: Typed env + faster boot
|
|
320
|
+
summary: Declare your environment once; it's validated at startup.
|
|
321
|
+
tags: [feature, performance] # feature | fix | breaking | security | performance
|
|
322
|
+
---
|
|
323
|
+
|
|
324
|
+
## What changed
|
|
325
|
+
|
|
326
|
+
Markdown body — code fences are highlighted at build time.
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`src/lib/releases.ts` reads every file via Vite's raw glob and turns them into typed, sorted records:
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
import { loadReleases } from '@voltro/changelog'
|
|
333
|
+
|
|
334
|
+
const sources = import.meta.glob('../../content/releases/*.mdx', {
|
|
335
|
+
query: '?raw', import: 'default', eager: true,
|
|
336
|
+
}) as Record<string, string>
|
|
337
|
+
|
|
338
|
+
export const releases = loadReleases(sources) // newest first, invalid files skipped
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`index.tsx` maps `releases` to the list. `[slug].tsx` enumerates the release slugs with `getStaticPaths` so `voltro build` pre-renders one HTML page per release, and its loader calls `highlightRelease` — at build time (Node) that runs shiki over the code fences; in the browser it's a no-op and the plain markdown HTML is used.
|
|
342
|
+
|
|
343
|
+
Both pages are `renderMode: 'static'` + `interactive: 'none'`, so the output is pure content HTML with no JS bundle.
|
|
344
|
+
|
|
345
|
+
## RSS feed
|
|
346
|
+
|
|
347
|
+
`pnpm build` runs `scripts/generate-rss.mjs` (which calls `renderReleaseRss`) before the static build, emitting `public/rss.xml` → served at `/rss.xml`. Set `SITE_URL` so the feed's links are absolute:
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
SITE_URL=https://changelog.example.com pnpm build && pnpm start
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
The feed is generated as a static file rather than a route because the build pre-renders HTML pages — a feed is a non-HTML artifact.
|
|
354
|
+
|
|
355
|
+
## "What's new" badge in another app
|
|
356
|
+
|
|
357
|
+
The browser-only `@voltro/changelog/web` subpath ships `useChangelogBadge`, which polls this site's `rss.xml` and tells a **product** app's nav when there's an unread release. Add it to that app (not this one):
|
|
358
|
+
|
|
359
|
+
```tsx
|
|
360
|
+
import { useChangelogBadge } from '@voltro/changelog/web'
|
|
361
|
+
|
|
362
|
+
const { hasUnread, latest, markRead } = useChangelogBadge({
|
|
363
|
+
url: 'https://changelog.example.com/rss.xml',
|
|
364
|
+
})
|
|
365
|
+
// render a dot when `hasUnread`; call `markRead()` when the user opens it.
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## Add a release
|
|
369
|
+
|
|
370
|
+
Drop a new `.mdx` file in `content/releases/`. Files missing `version` / `releasedAt` are skipped; the list re-sorts newest-first automatically and `voltro build` emits one more page.
|
|
371
|
+
|
|
372
|
+
## Pairs well with
|
|
373
|
+
|
|
374
|
+
- [`frontend-landing`](/docs/templates/landing) — the changelog shares the marketing site's brand.
|
|
375
|
+
|
|
376
|
+
## Anti-patterns
|
|
377
|
+
|
|
378
|
+
- **Rendering markdown client-side.** The template renders at build / loader time so content reaches React as pre-rendered HTML — faster, and indexable.
|
|
379
|
+
- **Putting the badge on the changelog site.** `useChangelogBadge` belongs in the product app whose users you want to notify, pointed at this site's feed — not on the changelog itself.
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
<!-- source: en/templates/spa.md -->
|
|
386
|
+
## SPA
|
|
387
|
+
|
|
388
|
+
_Pure client-rendered single-page app (renderMode: 'spa') — a fully interactive standalone tool, no backend, no SSR. State lives in the browser._
|
|
389
|
+
|
|
390
|
+
A pure client-rendered web app — a fully interactive standalone tool with NO backend and NO SSR. Every page sets `renderMode = 'spa'`, so `voltro build` skips pre-rendering and the client renders on load. That is the right call when the page's state lives entirely in the browser (here: `localStorage`), so a server-rendered first paint would just be discarded on hydration. The shipped example is a bill splitter — plain React, plain CSS, no rpc client, no loader. Template id: **`frontend-spa`**.
|
|
391
|
+
|
|
392
|
+
## Scaffold
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
voltro create-project acme --web=frontend-spa
|
|
396
|
+
voltro add-app splitter --template=frontend-spa --to acme
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
## What ships
|
|
400
|
+
|
|
401
|
+
```text
|
|
402
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
403
|
+
├── app.config.ts # type:web, theme:'system', defineEnv
|
|
404
|
+
├── package.json
|
|
405
|
+
├── tsconfig.json
|
|
406
|
+
├── README.md
|
|
407
|
+
└── src/
|
|
408
|
+
├── globals.css # self-contained plain CSS (no Tailwind / kit)
|
|
409
|
+
└── pages/
|
|
410
|
+
├── layout.tsx # imports globals.css, renders {children}
|
|
411
|
+
└── index.tsx # the bill splitter — renderMode: 'spa'
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
No rpc client, no loader, no `apis:` entry, no `@voltro/ui-shadcn` dependency. The app depends only on `@voltro/web` / `@voltro/client` / `@voltro/env` / `@voltro/cli` + React 19 — it's React plus the framework's web wiring and nothing else.
|
|
415
|
+
|
|
416
|
+
## `renderMode = 'spa'` — client-render, no pre-render
|
|
417
|
+
|
|
418
|
+
The page exports `renderMode = 'spa'`, which tells `voltro build` not to pre-render it: the client renders it on load. Use this when the whole page is interactive AND its state lives in the browser — an SSR'd first paint would just be discarded on hydration, so rendering it on the server buys nothing.
|
|
419
|
+
|
|
420
|
+
```tsx
|
|
421
|
+
// src/pages/index.tsx — a pure client-side SPA (a bill splitter)
|
|
422
|
+
import type { ReactNode } from 'react'
|
|
423
|
+
import { useEffect, useState } from 'react'
|
|
424
|
+
|
|
425
|
+
export const renderMode = 'spa' as const
|
|
426
|
+
// `interactive` defaults to 'full' — every component hydrates. That is
|
|
427
|
+
// correct for an SPA where the whole tree is interactive (don't reach for
|
|
428
|
+
// islands here; islands only help when most of the page is static).
|
|
429
|
+
|
|
430
|
+
const TIP_PRESETS = [10, 15, 18, 20] as const
|
|
431
|
+
const STORAGE_KEY = '{{appName}}:last-tip'
|
|
432
|
+
|
|
433
|
+
export default function BillSplitter(): ReactNode {
|
|
434
|
+
const [bill, setBill] = useState('')
|
|
435
|
+
const [tipPct, setTipPct] = useState(18)
|
|
436
|
+
const [people, setPeople] = useState(2)
|
|
437
|
+
// ...
|
|
438
|
+
}
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
`interactive` is left at its default `'full'` — the whole tree hydrates, which is right for an SPA. Islands only help when most of the page is static; here the JS *is* the page.
|
|
442
|
+
|
|
443
|
+
## Browser-only state via `localStorage`
|
|
444
|
+
|
|
445
|
+
The last tip % is remembered locally. Because `localStorage` is client-only, it's read in an effect (the effect body never runs on the server) — restore on mount, persist on change:
|
|
446
|
+
|
|
447
|
+
```tsx
|
|
448
|
+
// Restore the last tip % from localStorage — client-only, so read it in
|
|
449
|
+
// an effect (it never runs on the server).
|
|
450
|
+
useEffect(() => {
|
|
451
|
+
const saved = Number(window.localStorage.getItem(STORAGE_KEY))
|
|
452
|
+
if (Number.isFinite(saved) && saved > 0) setTipPct(saved)
|
|
453
|
+
}, [])
|
|
454
|
+
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
window.localStorage.setItem(STORAGE_KEY, String(tipPct))
|
|
457
|
+
}, [tipPct])
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
This is the canonical "spa is the right call" signal: state that only exists in the browser. With `renderMode: 'static'` the server would render a default tip % and the client would overwrite it on mount — a visible flash for zero benefit.
|
|
461
|
+
|
|
462
|
+
## Layout + styling — self-contained, no Tailwind
|
|
463
|
+
|
|
464
|
+
The root `layout.tsx` imports `globals.css` and renders its children. The SPA renders entirely client-side, so there's no SSR document to worry about:
|
|
465
|
+
|
|
466
|
+
```tsx
|
|
467
|
+
// src/pages/layout.tsx
|
|
468
|
+
import type { ReactNode } from 'react'
|
|
469
|
+
import '../globals.css'
|
|
470
|
+
|
|
471
|
+
export default function Layout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
472
|
+
return <>{children}</>
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
`globals.css` is hand-written plain CSS with light/dark custom properties — no `@import "tailwindcss"`, no `@source` glob, no `@voltro/ui-shadcn`. The dark variant keys off `:root.dark`, which the framework's pre-paint theme script toggles (see below):
|
|
477
|
+
|
|
478
|
+
```css
|
|
479
|
+
/* src/globals.css — self-contained, plain CSS */
|
|
480
|
+
:root {
|
|
481
|
+
--bg: #f4f4f5;
|
|
482
|
+
--card: #ffffff;
|
|
483
|
+
--fg: #18181b;
|
|
484
|
+
--muted: #71717a;
|
|
485
|
+
--accent: #0d9488;
|
|
486
|
+
--border: #e4e4e7;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
:root.dark {
|
|
490
|
+
--bg: #09090b;
|
|
491
|
+
--card: #18181b;
|
|
492
|
+
--fg: #fafafa;
|
|
493
|
+
--muted: #a1a1aa;
|
|
494
|
+
--accent: #2dd4bf;
|
|
495
|
+
--border: #27272a;
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## Config — `theme: 'system'` + a public env var
|
|
500
|
+
|
|
501
|
+
`app.config.ts` is the minimal web shape: `type: 'web'`, `theme: 'system'`, and a single public env var declared with `defineEnv`. `theme: 'system'` lets the framework's inline head script set the `:root.dark` class from the OS preference (or the `voltro:theme` cookie) BEFORE first paint — no light→dark flash, and no `useEffect` toggling the class:
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
// app.config.ts
|
|
505
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
506
|
+
|
|
507
|
+
export const env = defineEnv({
|
|
508
|
+
VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
export default {
|
|
512
|
+
type: 'web' as const,
|
|
513
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
514
|
+
port: {{port}},
|
|
515
|
+
theme: 'system' as const,
|
|
516
|
+
env,
|
|
517
|
+
}
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
The public var is browser-bundled, so it carries the mandatory `VOLTRO_PUBLIC_*` prefix and is read on the client via `publicEnv.VOLTRO_PUBLIC_APP_NAME` from `@voltro/env/public`.
|
|
521
|
+
|
|
522
|
+
## Build + serve
|
|
523
|
+
|
|
524
|
+
```bash
|
|
525
|
+
voltro build . # production build (no pre-render for spa pages)
|
|
526
|
+
voltro start . # serve the built dist/
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
## When to use frontend-spa vs. the other web templates
|
|
530
|
+
|
|
531
|
+
| You want… | Pick |
|
|
532
|
+
|---|---|
|
|
533
|
+
| A heavily interactive standalone tool whose state lives in the browser | `frontend-spa` |
|
|
534
|
+
| A blank React shell to bring your own brand | [`frontend-blank`](/docs/templates/blank) |
|
|
535
|
+
| A marketing landing surface | [`frontend-landing`](/docs/templates/landing) |
|
|
536
|
+
| A documentation site | [`frontend-docs`](/docs/templates/docs) |
|
|
537
|
+
|
|
538
|
+
Reach for `frontend-spa` only when the JS *is* the page. For content pages (marketing, blog, docs) prefer `renderMode: 'static'` and `island()` for the few interactive widgets — see the [render modes guide](/docs/routing/render-modes).
|
|
539
|
+
|
|
540
|
+
## Pairs well with
|
|
541
|
+
|
|
542
|
+
- **Nothing on the backend** — the point of this template is a self-contained client app. Add an api later (and an `apis:` entry in `app.config.ts`) only when you genuinely need server-side data.
|
|
543
|
+
- Any api template if you DO grow a backend — the web wiring is identical to the other web templates.
|
|
544
|
+
|
|
545
|
+
## Anti-patterns
|
|
546
|
+
|
|
547
|
+
- **Reaching for `interactive: 'islands'` because it "sounds faster".** Islands only help when most of the page is static. An SPA is interactive everywhere — every island becomes its own React root, which is *slower* than one full-tree hydration. Leave `interactive` at its default `'full'`.
|
|
548
|
+
- **Using `renderMode: 'spa'` for a content page.** If the page is mostly static text with a couple of interactive widgets, that's `renderMode: 'static'` + `island()`, not `spa` — you'd be throwing away SEO and first-paint for nothing.
|
|
549
|
+
- **Reading `localStorage` at module top-level or during render.** It doesn't exist on the server and isn't synchronous-safe across hydration. Read it in a `useEffect` (as the template does) so the body only runs client-side.
|
|
550
|
+
- **Toggling the `dark` class from a `useEffect`.** That paints a white flash on every navigation. The template sets `theme: 'system'` in `app.config.ts` and lets the framework's pre-paint script handle it — `globals.css` just reacts to `:root.dark`.
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
556
|
+
<!-- source: en/templates/ssr.md -->
|
|
557
|
+
## SSR + ISR
|
|
558
|
+
|
|
559
|
+
_Server-rendered pages (renderMode: 'ssr') and incrementally-cached pages (renderMode: 'isr' + revalidate / staleWhileRevalidate / tenantAware) — the render modes that need a runtime, not a static CDN._
|
|
560
|
+
|
|
561
|
+
The server-rendered render modes in one app — a per-request `renderMode: 'ssr'` page (fresh on every visit, reads cookies/headers via `useServerRequest()`) plus two `renderMode: 'isr'` pages that cache the rendered HTML and revalidate it on a window (one tenant-aware, one with stale-while-revalidate). Unlike a static site these render on a runtime, so you serve them with `voltro start`, NOT a bare CDN. It's self-contained — the loaders compute their own data, so it boots with zero infra; swap a loader for `ctx.query(...)` to pull from your api. Template id: **`frontend-ssr`**.
|
|
562
|
+
|
|
563
|
+
## Scaffold
|
|
564
|
+
|
|
565
|
+
```bash
|
|
566
|
+
voltro add-app web --template=frontend-ssr --to acme
|
|
567
|
+
voltro create-project acme --web=frontend-ssr
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
## What ships
|
|
571
|
+
|
|
572
|
+
```text
|
|
573
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
574
|
+
├── app.config.ts # type:web, theme:'system', defineEnv
|
|
575
|
+
├── package.json
|
|
576
|
+
├── tsconfig.json
|
|
577
|
+
├── README.md
|
|
578
|
+
└── src/
|
|
579
|
+
├── globals.css # self-contained plain CSS (no Tailwind / kit)
|
|
580
|
+
└── pages/
|
|
581
|
+
├── layout.tsx # nav + globals; <Link> client-nav
|
|
582
|
+
├── index.tsx # `/` — renderMode: 'ssr'
|
|
583
|
+
├── feed.tsx # `/feed` — renderMode: 'isr' + revalidate + tenantAware
|
|
584
|
+
└── feed-swr.tsx # `/feed-swr` — renderMode: 'isr' + staleWhileRevalidate
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
No rpc client, no `apis:` entry, no `@voltro/ui-shadcn` dependency. The app depends only on `@voltro/web` / `@voltro/client` / `@voltro/env` / `@voltro/cli` + React 19 — the three pages exist to demonstrate the SSR and ISR render modes, nothing else.
|
|
588
|
+
|
|
589
|
+
## SSR vs ISR — both need a runtime
|
|
590
|
+
|
|
591
|
+
These render modes do NOT pre-render at build time the way `static` does — they produce HTML on a server, per request. `voltro build` won't bake them into flat files, and `voltro start` is what serves them. A pure CDN can't run a loader per request, so don't ship these there.
|
|
592
|
+
|
|
593
|
+
| Page | Mode | What it demonstrates |
|
|
594
|
+
|---|---|---|
|
|
595
|
+
| `/` | `ssr` | Fresh per request; `useServerRequest()` reads cookies + headers server-side |
|
|
596
|
+
| `/feed` | `isr` + `revalidate` + `tenantAware` | Cached HTML, revalidated on a window, per-tenant cache key |
|
|
597
|
+
| `/feed-swr` | `isr` + `staleWhileRevalidate` | Serve stale instantly, refresh in the background |
|
|
598
|
+
|
|
599
|
+
The full reference for these modes lives in the [render modes guide](/docs/routing/render-modes).
|
|
600
|
+
|
|
601
|
+
## `renderMode = 'ssr'` — fresh on every request
|
|
602
|
+
|
|
603
|
+
`index.tsx` exports `renderMode = 'ssr'`. Its loader runs SERVER-SIDE on EVERY request under `voltro start` — the timestamp and nonce change on each refresh, and the loader can read the incoming request to personalise the HTML before it's sent. There is no caching: the response carries `x-voltro-rendered-by: ssr`.
|
|
604
|
+
|
|
605
|
+
```tsx
|
|
606
|
+
// src/pages/index.tsx
|
|
607
|
+
import type { ReactNode } from 'react'
|
|
608
|
+
import { useLoaderData, useServerRequest, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
609
|
+
|
|
610
|
+
export const renderMode = 'ssr' as const
|
|
611
|
+
|
|
612
|
+
export const meta: PageMeta = {
|
|
613
|
+
title: '{{capProjectName}} — SSR (fresh per request)',
|
|
614
|
+
description: 'Server-rendered on every request.',
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
interface HomeData {
|
|
618
|
+
readonly renderedAt: string
|
|
619
|
+
readonly nonce: number
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
export const loader: LoaderFn<HomeData> = async () => ({
|
|
623
|
+
renderedAt: new Date().toISOString(),
|
|
624
|
+
nonce: Math.floor(Math.random() * 1_000_000),
|
|
625
|
+
})
|
|
626
|
+
```
|
|
627
|
+
|
|
628
|
+
### Reading the request with `useServerRequest()`
|
|
629
|
+
|
|
630
|
+
`useServerRequest()` reads the request's cookies + headers — on the server during SSR, and the same shape on the client. This is the primitive for cookie-backed preferences (locale, theme) WITHOUT a hydration flash — the server reads the cookie before render starts, so the SSR'd HTML is already correct:
|
|
631
|
+
|
|
632
|
+
```tsx
|
|
633
|
+
export default function Home(): ReactNode {
|
|
634
|
+
const data = useLoaderData<HomeData>()
|
|
635
|
+
const req = useServerRequest()
|
|
636
|
+
const acceptLanguage = req?.headers['accept-language'] ?? 'unset'
|
|
637
|
+
const themeCookie = req?.cookies['voltro:theme'] ?? 'unset'
|
|
638
|
+
// … renders renderedAt, nonce, acceptLanguage, themeCookie …
|
|
639
|
+
}
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Use SSR for anything that varies per request: a logged-in dashboard, a page that reads the visitor's cookie/locale, or any data that must NOT be cached across requests.
|
|
643
|
+
|
|
644
|
+
## `renderMode = 'isr'` — cache + revalidate + tenant-aware key
|
|
645
|
+
|
|
646
|
+
`feed.tsx` takes the SAME render path as SSR, but `voltro start` CACHES the HTML and serves the cached copy to every request within the `revalidate` window. After the window expires the next request re-renders and replaces the cache — cache hits return in well under a millisecond. `tenantAware: true` folds the `x-tenant` request header into the cache key, so tenant A's render is never served to tenant B:
|
|
647
|
+
|
|
648
|
+
```tsx
|
|
649
|
+
// src/pages/feed.tsx
|
|
650
|
+
import type { ReactNode } from 'react'
|
|
651
|
+
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
652
|
+
|
|
653
|
+
export const renderMode = 'isr' as const
|
|
654
|
+
|
|
655
|
+
// Cache window — a number (seconds) OR a string like '1 hour' / '30 seconds'.
|
|
656
|
+
export const revalidate = '10 seconds'
|
|
657
|
+
|
|
658
|
+
// Tenant-aware caching: the cache key includes the `x-tenant` request header,
|
|
659
|
+
// so tenant A's render is never served to tenant B. Leave it off for pages
|
|
660
|
+
// that don't vary by tenant (they then share ONE cache entry).
|
|
661
|
+
export const tenantAware = true
|
|
662
|
+
|
|
663
|
+
export const meta: PageMeta = {
|
|
664
|
+
title: '{{capProjectName}} — ISR (cached + revalidated)',
|
|
665
|
+
description: 'Cached HTML, revalidated on a window.',
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
interface FeedData {
|
|
669
|
+
readonly renderedAt: string
|
|
670
|
+
readonly nonce: number
|
|
671
|
+
readonly tenant: string
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export const loader: LoaderFn<FeedData> = async ({ headers }) => ({
|
|
675
|
+
renderedAt: new Date().toISOString(),
|
|
676
|
+
nonce: Math.floor(Math.random() * 1_000_000),
|
|
677
|
+
tenant: headers?.['x-tenant'] ?? 'anonymous',
|
|
678
|
+
})
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
The loader's `headers` is populated server-side (lowercased keys) — that's how `/feed` reads `x-tenant` to label the row. Watch the response headers to see the cache work: `x-voltro-cache: MISS` on the first request (it rendered), then `HIT` within the window, then `MISS` again after it expires.
|
|
682
|
+
|
|
683
|
+
## `staleWhileRevalidate` — serve stale, refresh in the background
|
|
684
|
+
|
|
685
|
+
`feed-swr.tsx` is the same ISR page plus a `staleWhileRevalidate` window. When `revalidate` expires, instead of blocking the next visitor on a fresh render, the cache serves the STALE HTML immediately AND kicks off a background re-render. The visitor never waits; the cache catches up out-of-band. Beyond `revalidate + staleWhileRevalidate` the next request blocks on a fresh render, like a cold MISS:
|
|
686
|
+
|
|
687
|
+
```tsx
|
|
688
|
+
// src/pages/feed-swr.tsx
|
|
689
|
+
import type { ReactNode } from 'react'
|
|
690
|
+
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
691
|
+
|
|
692
|
+
export const renderMode = 'isr' as const
|
|
693
|
+
export const revalidate = '5 seconds'
|
|
694
|
+
// Within this window AFTER `revalidate` expires, requests get the STALE HTML
|
|
695
|
+
// instantly + a background refresh runs. Beyond (revalidate + swr) the next
|
|
696
|
+
// request blocks on a fresh render, like plain ISR.
|
|
697
|
+
export const staleWhileRevalidate = '30 seconds'
|
|
698
|
+
|
|
699
|
+
export const meta: PageMeta = {
|
|
700
|
+
title: '{{capProjectName}} — stale-while-revalidate',
|
|
701
|
+
description: 'Serve stale instantly, refresh in the background.',
|
|
702
|
+
}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
The response header tells you which path served the page: `x-voltro-cache: MISS` → `HIT` → `STALE` (background refresh) → `MISS` again after the SWR window.
|
|
706
|
+
|
|
707
|
+
## Layout — nav + globals, full hydration
|
|
708
|
+
|
|
709
|
+
The root `layout.tsx` imports `globals.css` and renders a nav. SSR/ISR pages hydrate fully by default, so `<Link>` does client-side navigation after the first load:
|
|
710
|
+
|
|
711
|
+
```tsx
|
|
712
|
+
// src/pages/layout.tsx
|
|
713
|
+
import type { ReactNode } from 'react'
|
|
714
|
+
import { Link } from '@voltro/web'
|
|
715
|
+
import '../globals.css'
|
|
716
|
+
|
|
717
|
+
export default function Layout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
718
|
+
return (
|
|
719
|
+
<>
|
|
720
|
+
<nav className="top">
|
|
721
|
+
<Link to="/">/ (ssr)</Link>
|
|
722
|
+
<Link to="/feed">/feed (isr)</Link>
|
|
723
|
+
<Link to="/feed-swr">/feed-swr (isr + swr)</Link>
|
|
724
|
+
</nav>
|
|
725
|
+
<main>{children}</main>
|
|
726
|
+
</>
|
|
727
|
+
)
|
|
728
|
+
}
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
`globals.css` is hand-written plain CSS with light/dark custom properties — no `@import "tailwindcss"`, no `@source` glob, no `@voltro/ui-shadcn`. The dark variant keys off `:root.dark`, which the framework's pre-paint theme script toggles (driven by `theme: 'system'` in `app.config.ts`).
|
|
732
|
+
|
|
733
|
+
## Config — self-contained, zero infra
|
|
734
|
+
|
|
735
|
+
`app.config.ts` is the minimal web shape: `type: 'web'`, `theme: 'system'`, and a single public env var. There's NO `apis:` entry — the loaders compute their own data, so the app boots with zero backend:
|
|
736
|
+
|
|
737
|
+
```ts
|
|
738
|
+
// app.config.ts
|
|
739
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
740
|
+
|
|
741
|
+
export const env = defineEnv({
|
|
742
|
+
VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
export default {
|
|
746
|
+
type: 'web' as const,
|
|
747
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
748
|
+
port: {{port}},
|
|
749
|
+
theme: 'system' as const,
|
|
750
|
+
env,
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
### Pulling fresh data from your api
|
|
755
|
+
|
|
756
|
+
To render from YOUR reactive backend instead of computing the data in the loader, declare an `apis` entry in `app.config.ts` and call `ctx.query` in an ssr/isr loader. `query` is present ONLY server-side (ssr/isr); it invokes the api's rpc directly over `POST /rpc`, forwarding the session cookie so the SAME Subject + tenant resolve as the WS path:
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
// app.config.ts: apis: { app: { package: '@{{projectName}}/api' } }
|
|
760
|
+
export const loader: LoaderFn<Data> = async ({ query }) => ({
|
|
761
|
+
post: await query!('posts.get', { id: '…' }), // `query` is server-only
|
|
762
|
+
})
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
In the browser, fetch live data with `useSubscription` in the component — the loader feeds the SSR first paint + the document `<title>`, the subscription keeps it live.
|
|
766
|
+
|
|
767
|
+
## Build + serve
|
|
768
|
+
|
|
769
|
+
```bash
|
|
770
|
+
voltro build . # production build + pre-built SSR bundle
|
|
771
|
+
voltro start . # production runtime — serves SSR/ISR on demand, runs the cache
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
The ISR cache only runs under `voltro start`. Watch the response headers there:
|
|
775
|
+
|
|
776
|
+
```bash
|
|
777
|
+
curl -i http://localhost:5190/ | grep x-voltro # x-voltro-rendered-by: ssr
|
|
778
|
+
curl -i http://localhost:5190/feed | grep x-voltro # x-voltro-cache: MISS, then HIT
|
|
779
|
+
```
|
|
780
|
+
|
|
781
|
+
## When to use frontend-ssr vs. the other web templates
|
|
782
|
+
|
|
783
|
+
| You want… | Pick |
|
|
784
|
+
|---|---|
|
|
785
|
+
| Per-request HTML (cookies, auth) and/or cached-but-fresh content | `frontend-ssr` |
|
|
786
|
+
| A heavily interactive standalone tool whose state lives in the browser | [`frontend-spa`](/docs/templates/spa) |
|
|
787
|
+
| A blank React shell to bring your own brand | [`frontend-blank`](/docs/templates/blank) |
|
|
788
|
+
| A marketing landing surface | [`frontend-landing`](/docs/templates/landing) |
|
|
789
|
+
| A documentation site | [`frontend-docs`](/docs/templates/docs) |
|
|
790
|
+
|
|
791
|
+
Reach for `frontend-ssr` only when a page's HTML must differ per visitor (`ssr`) or must be cached-but-fresh (`isr`). For pages that are identical for everyone and stable for a whole deploy, prefer `renderMode: 'static'` — see the [render modes guide](/docs/routing/render-modes).
|
|
792
|
+
|
|
793
|
+
## Pairs well with
|
|
794
|
+
|
|
795
|
+
- **Any api template** when you grow a backend — add an `apis:` entry in `app.config.ts` and call `ctx.query(...)` in the ssr/isr loaders to render from your reactive data.
|
|
796
|
+
- [Self-hosting](/docs/deployment/self-hosting) on `voltro start` / a container — SSR/ISR need a runtime; only the `static` pages go to a [CDN](/docs/deployment/static-sites).
|
|
797
|
+
|
|
798
|
+
## Anti-patterns
|
|
799
|
+
|
|
800
|
+
- **Shipping SSR/ISR pages to a pure CDN.** They need a runtime to render per request — `voltro static deploy` will (correctly) refuse them. Serve them on `voltro start` / a container, and push only your `static` pages to a CDN.
|
|
801
|
+
- **Reaching for `useServerRequest()` AFTER a user interaction.** It's for reading the request DURING the server render. To react to a button click that writes a cookie, use a regular event handler + `document.cookie` — SSR doesn't matter there.
|
|
802
|
+
- **Using `renderMode: 'ssr'` for a page that's the same for everyone.** SSR re-renders on every request with no caching. If the HTML doesn't vary per visitor, that's `renderMode: 'static'` (identical for a whole deploy) or `renderMode: 'isr'` (cached + revalidated) — SSR there is wasted work on every hit.
|
|
803
|
+
- **Setting `scope`-less / `tenantAware: false` on a per-tenant ISR page.** Without `tenantAware: true` an ISR page shares ONE cache entry across tenants — tenant A's render would be served to tenant B. Turn it on for anything that varies by `x-tenant`; leave it off only for pages that are genuinely the same for every tenant.
|
|
804
|
+
- **Calling `query!(...)` without guarding for the client.** `ctx.query` is `undefined` for client-side loader invocations — only rely on it under `renderMode: 'ssr' | 'isr'`, and use `useSubscription` in the component for live browser data.
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
---
|
|
809
|
+
|
|
810
|
+
<!-- source: en/templates/static-blog.md -->
|
|
811
|
+
## Static blog
|
|
812
|
+
|
|
813
|
+
_A static SSG blog (renderMode: 'static') — dynamic [slug] routes pre-rendered at build via getStaticPaths from a content source, per-post meta from loaderData, and ONE island for selective hydration. No backend._
|
|
814
|
+
|
|
815
|
+
A static, pre-rendered blog — every page is built to flat HTML by `voltro build`, so the browser downloads HTML and (almost) nothing else. It's the canonical **CMS→static** shape: a dynamic `[slug]` route is enumerated at build time by `getStaticPaths` reading a content source, each post's `<title>`/`<description>` comes from its `loader` data, and the one bit that moves — a reading-progress bar — is a single `island()` hydrated inside otherwise-inert HTML. No api, no rpc client, no SSR. Template id: **`frontend-static-blog`**.
|
|
816
|
+
|
|
817
|
+
## Scaffold
|
|
818
|
+
|
|
819
|
+
```bash
|
|
820
|
+
voltro create-project acme --web=frontend-static-blog
|
|
821
|
+
voltro add-app blog --template=frontend-static-blog --to acme
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
## What ships
|
|
825
|
+
|
|
826
|
+
```text
|
|
827
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
828
|
+
├── app.config.ts # type:web, theme:'system', defineEnv (public-only)
|
|
829
|
+
├── package.json
|
|
830
|
+
├── tsconfig.json
|
|
831
|
+
├── README.md
|
|
832
|
+
└── src/
|
|
833
|
+
├── globals.css
|
|
834
|
+
├── content/
|
|
835
|
+
│ └── posts.ts # the content source (the "CMS") — single source of truth
|
|
836
|
+
├── components/
|
|
837
|
+
│ └── ReadingProgress.island.tsx # the ONE island — island() + hydrate: 'load'
|
|
838
|
+
└── pages/
|
|
839
|
+
├── layout.tsx # imports globals.css, site header
|
|
840
|
+
├── index.tsx # the post LIST — interactive: 'none' (zero JS)
|
|
841
|
+
└── blog/
|
|
842
|
+
└── [slug].tsx # per-post page — getStaticPaths + loader + meta + the island
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
No rpc client, no loader fetching a backend, no `apis:` entry. The app depends only on `@voltro/web` / `@voltro/client` / `@voltro/env` / `@voltro/cli` + React 19 — the same minimal web wiring as `frontend-spa`, but every page is `renderMode: 'static'` instead of `'spa'`.
|
|
846
|
+
|
|
847
|
+
## The content source — one swappable array (the "CMS")
|
|
848
|
+
|
|
849
|
+
`src/content/posts.ts` is a plain in-repo array: the simplest stand-in for a CMS. It is imported by the index page (the list), the `[slug]` page (the detail), AND `getStaticPaths` — so it is the single source of truth for "which posts exist". In a real app you replace the body of `getStaticPaths` + the page loader to read from a CMS API, a database query, or a folder of markdown; nothing else changes.
|
|
850
|
+
|
|
851
|
+
```ts
|
|
852
|
+
// src/content/posts.ts
|
|
853
|
+
export interface Post {
|
|
854
|
+
/** URL segment — `/blog/<slug>`. Must be unique + DNS-safe. */
|
|
855
|
+
readonly slug: string
|
|
856
|
+
readonly title: string
|
|
857
|
+
/** ISO date, shown + used for ordering. */
|
|
858
|
+
readonly date: string
|
|
859
|
+
/** One-line summary — feeds the list + the `<meta name="description">`. */
|
|
860
|
+
readonly excerpt: string
|
|
861
|
+
readonly readingMinutes: number
|
|
862
|
+
/** Body paragraphs, separated by a blank line. */
|
|
863
|
+
readonly body: string
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export const posts: ReadonlyArray<Post> = [
|
|
867
|
+
{
|
|
868
|
+
slug: 'hello-static',
|
|
869
|
+
title: 'Why this blog ships zero JavaScript',
|
|
870
|
+
date: '2025-01-15',
|
|
871
|
+
excerpt: 'Every page is pre-rendered at build time, so the browser downloads HTML and nothing else.',
|
|
872
|
+
readingMinutes: 3,
|
|
873
|
+
body: `…`,
|
|
874
|
+
},
|
|
875
|
+
// …more posts…
|
|
876
|
+
]
|
|
877
|
+
```
|
|
878
|
+
|
|
879
|
+
## SSG dynamic routes — `getStaticPaths` + `loader` + per-post `meta`
|
|
880
|
+
|
|
881
|
+
A dynamic route like `src/pages/blog/[slug].tsx` matches infinitely many URLs. To pre-render it statically the build needs to know WHICH slugs exist — that is what `getStaticPaths` answers, by mapping over the content source. For each enumerated `params` the build runs the `loader` (server-side, at build time) and renders one HTML file. The `meta` export is a FUNCTION of the loader data, so each post gets its own correct `<title>` / `<description>` baked into the HTML — exactly what you want for SEO and social cards.
|
|
882
|
+
|
|
883
|
+
```tsx
|
|
884
|
+
// src/pages/blog/[slug].tsx
|
|
885
|
+
import type { ReactNode } from 'react'
|
|
886
|
+
import { useLoaderData, notFound, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
887
|
+
import { posts, type Post } from '../../content/posts'
|
|
888
|
+
import ReadingProgress from '../../components/ReadingProgress.island'
|
|
889
|
+
|
|
890
|
+
export const renderMode = 'static' as const
|
|
891
|
+
export const interactive = 'islands' as const
|
|
892
|
+
|
|
893
|
+
// Which concrete paths to pre-render. Every returned `params` becomes one
|
|
894
|
+
// built HTML file; un-enumerated slugs are simply not built (→ 404).
|
|
895
|
+
export const getStaticPaths = async (): Promise<Array<{ params: { slug: string } }>> =>
|
|
896
|
+
posts.map((post) => ({ params: { slug: post.slug } }))
|
|
897
|
+
|
|
898
|
+
// Runs at build time for each enumerated slug. Returning `notFound()` skips
|
|
899
|
+
// the artifact at build / 404s at runtime — defensive, though getStaticPaths
|
|
900
|
+
// only ever feeds us slugs that exist.
|
|
901
|
+
export const loader: LoaderFn<Post> = async ({ params }) => {
|
|
902
|
+
const post = posts.find((p) => p.slug === params.slug)
|
|
903
|
+
if (!post) return notFound(`post ${params.slug}`)
|
|
904
|
+
return post
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// `meta` as a function of the loader data → correct per-post <title> in the
|
|
908
|
+
// pre-rendered HTML (great for SEO + social cards).
|
|
909
|
+
export const meta = ({ loaderData }: { loaderData: Post }): PageMeta => ({
|
|
910
|
+
title: `${loaderData.title} — Acme`,
|
|
911
|
+
description: loaderData.excerpt,
|
|
912
|
+
})
|
|
913
|
+
|
|
914
|
+
export default function BlogPost(): ReactNode {
|
|
915
|
+
const post = useLoaderData<Post>()
|
|
916
|
+
return (
|
|
917
|
+
<main>
|
|
918
|
+
<ReadingProgress />
|
|
919
|
+
<p><a href="/">← All posts</a></p>
|
|
920
|
+
<article>
|
|
921
|
+
<h1>{post.title}</h1>
|
|
922
|
+
<p className="muted">{post.date} · {post.readingMinutes} min read</p>
|
|
923
|
+
{post.body.split('\n\n').map((para, i) => (
|
|
924
|
+
<p key={i}>{para}</p>
|
|
925
|
+
))}
|
|
926
|
+
</article>
|
|
927
|
+
</main>
|
|
928
|
+
)
|
|
929
|
+
}
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
Point `getStaticPaths` + `loader` at a CMS/DB/filesystem and the build will pre-render exactly those pages — the "static generation from a content source" pattern, with no extra config. A pure `'static'` page 404s for slugs you didn't enumerate, which is exactly right for a fixed content set; if you ALSO set `renderMode: 'ssr' | 'isr'`, `voltro start` renders the un-enumerated ones on demand.
|
|
933
|
+
|
|
934
|
+
## The ONE island — selective hydration
|
|
935
|
+
|
|
936
|
+
The post list ships zero JavaScript, but the post page wants a thin reading-progress bar that tracks scrolling. Hydrating the whole article to get that one widget would be wasteful. Instead the page declares `interactive: 'islands'` and the bar is wrapped in `island()`: the framework renders the article once as inert HTML and attaches React ONLY to the island marker — the surrounding HTML never runs a React lifecycle.
|
|
937
|
+
|
|
938
|
+
```tsx
|
|
939
|
+
// src/components/ReadingProgress.island.tsx
|
|
940
|
+
import type { ReactNode } from 'react'
|
|
941
|
+
import { useEffect, useState } from 'react'
|
|
942
|
+
import { island } from '@voltro/web'
|
|
943
|
+
|
|
944
|
+
function ReadingProgress(): ReactNode {
|
|
945
|
+
const [pct, setPct] = useState(0)
|
|
946
|
+
|
|
947
|
+
useEffect(() => {
|
|
948
|
+
const onScroll = (): void => {
|
|
949
|
+
const el = document.documentElement
|
|
950
|
+
const max = el.scrollHeight - el.clientHeight
|
|
951
|
+
setPct(max > 0 ? Math.min(100, (el.scrollTop / max) * 100) : 0)
|
|
952
|
+
}
|
|
953
|
+
onScroll()
|
|
954
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
955
|
+
window.addEventListener('resize', onScroll)
|
|
956
|
+
return () => {
|
|
957
|
+
window.removeEventListener('scroll', onScroll)
|
|
958
|
+
window.removeEventListener('resize', onScroll)
|
|
959
|
+
}
|
|
960
|
+
}, [])
|
|
961
|
+
|
|
962
|
+
return <div className="reading-progress" style={{ width: `${pct}%` }} aria-hidden="true" />
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export default island(ReadingProgress, { name: 'ReadingProgress', hydrate: 'load' })
|
|
966
|
+
```
|
|
967
|
+
|
|
968
|
+
`hydrate: 'load'` wakes the island immediately — it must track scrolling from the very first frame. Other strategies defer the cost: `'idle'`, `'visible'` (on scroll-into-view, the best default below the fold), `'interaction'` (on first pointer/key), `'never'` (inert HTML forever).
|
|
969
|
+
|
|
970
|
+
## The list page — `interactive: 'none'` (zero JS)
|
|
971
|
+
|
|
972
|
+
The index at `/` is pure content: a list of posts with no interactivity. It declares `interactive: 'none'`, which makes the framework strip EVERY `<script>` it would otherwise emit — the HTML ships with no JS bundle at all. It reads the same content source the `[slug]` page + `getStaticPaths` use:
|
|
973
|
+
|
|
974
|
+
```tsx
|
|
975
|
+
// src/pages/index.tsx
|
|
976
|
+
import type { ReactNode } from 'react'
|
|
977
|
+
import type { PageMeta } from '@voltro/web'
|
|
978
|
+
import { posts } from '../content/posts'
|
|
979
|
+
|
|
980
|
+
export const renderMode = 'static' as const
|
|
981
|
+
export const interactive = 'none' as const
|
|
982
|
+
|
|
983
|
+
export const meta: PageMeta = {
|
|
984
|
+
title: 'Acme — Blog',
|
|
985
|
+
description: 'A static, pre-rendered blog built with Voltro.',
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Newest first — sort a copy so the source array stays stable.
|
|
989
|
+
const byDateDesc = [...posts].sort((a, b) => b.date.localeCompare(a.date))
|
|
990
|
+
|
|
991
|
+
export default function Index(): ReactNode {
|
|
992
|
+
return (
|
|
993
|
+
<main>
|
|
994
|
+
<h1>Blog</h1>
|
|
995
|
+
<p className="muted">{posts.length} posts · pre-rendered at build time</p>
|
|
996
|
+
<ul className="post-list">
|
|
997
|
+
{byDateDesc.map((post) => (
|
|
998
|
+
<li key={post.slug}>
|
|
999
|
+
<a href={`/blog/${post.slug}`}>{post.title}</a>
|
|
1000
|
+
<p>
|
|
1001
|
+
<span className="muted">{post.date} · {post.readingMinutes} min</span>
|
|
1002
|
+
{' — '}
|
|
1003
|
+
{post.excerpt}
|
|
1004
|
+
</p>
|
|
1005
|
+
</li>
|
|
1006
|
+
))}
|
|
1007
|
+
</ul>
|
|
1008
|
+
</main>
|
|
1009
|
+
)
|
|
1010
|
+
}
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
The layout uses a plain `<a>` (not `<Link>`) deliberately: the list is `interactive: 'none'` and the posts are `'islands'`, so there is no router runtime to intercept clicks — a real browser navigation is the correct, robust behaviour for a static content site.
|
|
1014
|
+
|
|
1015
|
+
## Config — `theme: 'system'` + a public env var
|
|
1016
|
+
|
|
1017
|
+
`app.config.ts` is the minimal web shape: a single public env var declared with `defineEnv`, and `theme: 'system'` so the framework's inline head script sets the `:root.dark` class from the OS preference (or the `voltro:theme` cookie) BEFORE first paint — no light→dark flash. A static site has no server, so a `secret` here makes no sense; everything is `public` (browser-bundled, `VOLTRO_PUBLIC_*`-prefixed):
|
|
1018
|
+
|
|
1019
|
+
```ts
|
|
1020
|
+
// app.config.ts
|
|
1021
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
1022
|
+
|
|
1023
|
+
export const env = defineEnv({
|
|
1024
|
+
VOLTRO_PUBLIC_SITE_NAME: envVar.string({ access: 'public', default: 'Acme' }),
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
export default {
|
|
1028
|
+
type: 'web' as const,
|
|
1029
|
+
name: 'AcmeBlog',
|
|
1030
|
+
port: 5173,
|
|
1031
|
+
theme: 'system' as const,
|
|
1032
|
+
env,
|
|
1033
|
+
}
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
## Build + serve
|
|
1037
|
+
|
|
1038
|
+
```bash
|
|
1039
|
+
voltro build . # pre-render → dist/ (one HTML file per post)
|
|
1040
|
+
voltro start . # serve the built dist/ locally
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
Because the output is pure static files, you can push `dist/` to any CDN instead of running a server. Add a post by appending to `src/content/posts.ts`; `getStaticPaths` reads the same array, so a new entry is pre-rendered on the next build — no route config.
|
|
1044
|
+
|
|
1045
|
+
## When to use frontend-static-blog vs. the other web templates
|
|
1046
|
+
|
|
1047
|
+
| You want… | Pick |
|
|
1048
|
+
|---|---|
|
|
1049
|
+
| A content site (blog/docs/marketing) pre-rendered to flat HTML with a few interactive widgets | `frontend-static-blog` |
|
|
1050
|
+
| A heavily interactive standalone tool whose state lives in the browser | [`frontend-spa`](/docs/templates/spa) |
|
|
1051
|
+
| A blank React shell to bring your own brand | [`frontend-blank`](/docs/templates/blank) |
|
|
1052
|
+
| A marketing landing surface | [`frontend-landing`](/docs/templates/landing) |
|
|
1053
|
+
| A documentation site | [`frontend-docs`](/docs/templates/docs) |
|
|
1054
|
+
|
|
1055
|
+
## Pairs well with
|
|
1056
|
+
|
|
1057
|
+
- **Nothing on the backend** — the point of this template is a self-contained static site. Add an api later (and an `apis:` entry in `app.config.ts`) only when a page genuinely needs live, request-time data — at which point flip that page to `renderMode: 'ssr' | 'isr'`.
|
|
1058
|
+
- Any api template if the blog grows a comments section or admin surface — the web wiring is identical to the other web templates.
|
|
1059
|
+
|
|
1060
|
+
## Anti-patterns
|
|
1061
|
+
|
|
1062
|
+
- **Reaching for `interactive: 'islands'` on a page that's interactive everywhere.** Islands only help when most of the page is static and a few widgets move (this template: a static article + one progress bar). A dashboard where everything is interactive wants `interactive: 'full'` — every island would be its own React root, which is *slower* than one full-tree hydration. See the [render modes guide](/docs/routing/render-modes).
|
|
1063
|
+
- **Forgetting `getStaticPaths` on a dynamic `[slug]` route.** Without it the build can't know which concrete URLs to emit, so NO post page is pre-rendered. Every dynamic segment that's `renderMode: 'static'` needs `getStaticPaths` to enumerate its paths from the content source.
|
|
1064
|
+
- **Returning the wrong shape from `meta`.** `meta` here is a function of `{ loaderData }` — read the post's fields off `loaderData`, don't hardcode a single title for every post, or every page gets the same `<title>` and you lose the per-post SEO win.
|
|
1065
|
+
- **Shipping JS on the `interactive: 'none'` list page.** The framework strips the bundle on purpose — don't paper over it with a Suspense boundary or a `requestIdleCallback` re-import. If a page genuinely needs interactivity, mark it `'islands'` (wrap the moving part in `island()`) or `'full'`.
|
|
1066
|
+
- **Using `<a>` to a CMS/DB query at request time on a `'static'` page.** A `'static'` page's loader runs at BUILD time only — there is no per-request render. If the data must be fresh per visit, the page is `renderMode: 'ssr'` (or `'isr'` for cached-with-revalidation), not `'static'`.
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
---
|
|
1071
|
+
|
|
1072
|
+
<!-- source: en/templates/contact.md -->
|
|
1073
|
+
## Contact form
|
|
1074
|
+
|
|
1075
|
+
_Static page + a serverless email contact form — an island form POSTs to a bundled *.serverless.ts that sends mail via Resend. Page → CDN, function → scales to zero._
|
|
1076
|
+
|
|
1077
|
+
The headline "static frontend, serverless backend" combo: a pre-rendered page (`renderMode: 'static'`) whose only hydrated part is an `island()` contact form, which POSTs JSON to a **standalone** `functions/sendMessage.serverless.ts`. That function is NOT part of a long-running api — the CLI bundles + ships it on its own, it sends mail via Resend over `HttpClient`, and it scales to zero. The page can go to any CDN; the function deploys SEPARATELY with `voltro serverless`. That split — page on the edge, backend on demand — is the whole point of the template. Template id: **`frontend-contact`**.
|
|
1078
|
+
|
|
1079
|
+
## Scaffold
|
|
1080
|
+
|
|
1081
|
+
```bash
|
|
1082
|
+
voltro create-project acme --web=frontend-contact
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
## What ships
|
|
1086
|
+
|
|
1087
|
+
```text
|
|
1088
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
1089
|
+
├── app.config.ts # type:web, theme:'system', defineEnv
|
|
1090
|
+
├── package.json # scripts: dev, build, fn:dev, fn:deploy
|
|
1091
|
+
├── tsconfig.json
|
|
1092
|
+
├── README.md
|
|
1093
|
+
├── functions/
|
|
1094
|
+
│ └── sendMessage.serverless.ts # the serverless backend — defineServerless, sends mail
|
|
1095
|
+
└── src/
|
|
1096
|
+
├── globals.css
|
|
1097
|
+
├── config.ts # CONTACT_ENDPOINT — where the form POSTs
|
|
1098
|
+
├── components/
|
|
1099
|
+
│ └── ContactForm.island.tsx # the only hydrated part (an island)
|
|
1100
|
+
└── pages/
|
|
1101
|
+
├── layout.tsx # imports globals.css, renders {children}
|
|
1102
|
+
└── index.tsx # static page — renderMode:'static', interactive:'islands'
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
The serverless function lives next to the web app but is a separate deploy artifact — it depends on `@voltro/serverless` (+ `@effect/platform` for `HttpClient`). The page itself depends only on `@voltro/web` / `@voltro/client` / `@voltro/env` / `@voltro/cli` + React 19.
|
|
1106
|
+
|
|
1107
|
+
## The static page + the island form
|
|
1108
|
+
|
|
1109
|
+
`src/pages/index.tsx` is `renderMode: 'static'` (pre-rendered, servable from a CDN) and `interactive: 'islands'` — so only the `<ContactForm>` island hydrates; the headline + copy ship as inert HTML with no React lifecycle:
|
|
1110
|
+
|
|
1111
|
+
```tsx
|
|
1112
|
+
// src/pages/index.tsx
|
|
1113
|
+
import type { ReactNode } from 'react'
|
|
1114
|
+
import type { PageMeta } from '@voltro/web'
|
|
1115
|
+
import ContactForm from '../components/ContactForm.island'
|
|
1116
|
+
|
|
1117
|
+
export const renderMode = 'static' as const
|
|
1118
|
+
export const interactive = 'islands' as const
|
|
1119
|
+
|
|
1120
|
+
export const meta: PageMeta = {
|
|
1121
|
+
title: '{{capProjectName}} — Get in touch',
|
|
1122
|
+
description: 'Static page, serverless contact form.',
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
export default function Index(): ReactNode {
|
|
1126
|
+
return (
|
|
1127
|
+
<main>
|
|
1128
|
+
<section className="hero">
|
|
1129
|
+
<h1>{{capProjectName}}</h1>
|
|
1130
|
+
<p className="lead">
|
|
1131
|
+
This page is pre-rendered and ships from a CDN. The form below talks to
|
|
1132
|
+
a serverless function that scales to zero — no always-on server in sight.
|
|
1133
|
+
</p>
|
|
1134
|
+
</section>
|
|
1135
|
+
|
|
1136
|
+
<section className="contact">
|
|
1137
|
+
<h2>Send us a message</h2>
|
|
1138
|
+
<ContactForm />
|
|
1139
|
+
</section>
|
|
1140
|
+
</main>
|
|
1141
|
+
)
|
|
1142
|
+
}
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
The form is an island with `hydrate: 'visible'` — it's below the fold, so hydration defers until it scrolls into view. On submit it POSTs JSON to `CONTACT_ENDPOINT` (the serverless function), which lives on a **different origin**; the call works because the function's dev runner AND the edge hosts send CORS headers:
|
|
1146
|
+
|
|
1147
|
+
```tsx
|
|
1148
|
+
// src/components/ContactForm.island.tsx
|
|
1149
|
+
import type { FormEvent, ReactNode } from 'react'
|
|
1150
|
+
import { useState } from 'react'
|
|
1151
|
+
import { island } from '@voltro/web'
|
|
1152
|
+
import { CONTACT_ENDPOINT } from '../config'
|
|
1153
|
+
|
|
1154
|
+
type Status =
|
|
1155
|
+
| { readonly kind: 'idle' }
|
|
1156
|
+
| { readonly kind: 'sending' }
|
|
1157
|
+
| { readonly kind: 'sent' }
|
|
1158
|
+
| { readonly kind: 'error'; readonly message: string }
|
|
1159
|
+
|
|
1160
|
+
function ContactForm(): ReactNode {
|
|
1161
|
+
const [name, setName] = useState('')
|
|
1162
|
+
const [email, setEmail] = useState('')
|
|
1163
|
+
const [message, setMessage] = useState('')
|
|
1164
|
+
const [status, setStatus] = useState<Status>({ kind: 'idle' })
|
|
1165
|
+
|
|
1166
|
+
const onSubmit = async (e: FormEvent): Promise<void> => {
|
|
1167
|
+
e.preventDefault()
|
|
1168
|
+
setStatus({ kind: 'sending' })
|
|
1169
|
+
try {
|
|
1170
|
+
const res = await fetch(CONTACT_ENDPOINT, {
|
|
1171
|
+
method: 'POST',
|
|
1172
|
+
headers: { 'content-type': 'application/json' },
|
|
1173
|
+
body: JSON.stringify({ name, email, message }),
|
|
1174
|
+
})
|
|
1175
|
+
const body = (await res.json().catch(() => ({}))) as { error?: string; detail?: string }
|
|
1176
|
+
if (!res.ok) {
|
|
1177
|
+
setStatus({ kind: 'error', message: body.detail ?? body.error ?? `HTTP ${res.status}` })
|
|
1178
|
+
return
|
|
1179
|
+
}
|
|
1180
|
+
setStatus({ kind: 'sent' })
|
|
1181
|
+
setName(''); setEmail(''); setMessage('')
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
setStatus({ kind: 'error', message: err instanceof Error ? err.message : 'network error' })
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// ...form JSX (name / email / message inputs + a submit button)...
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
export default island(ContactForm, { name: 'ContactForm', hydrate: 'visible' })
|
|
1190
|
+
```
|
|
1191
|
+
|
|
1192
|
+
`CONTACT_ENDPOINT` is a plain constant in `src/config.ts` (not `publicEnv`) so the template typechecks BEFORE `voltro dev` generates the env types. Its default is the local serverless dev port, so the form works the moment you run `fn:dev` in another terminal:
|
|
1193
|
+
|
|
1194
|
+
```ts
|
|
1195
|
+
// src/config.ts
|
|
1196
|
+
export const CONTACT_ENDPOINT = 'http://localhost:8910'
|
|
1197
|
+
```
|
|
1198
|
+
|
|
1199
|
+
## The serverless function — `defineServerless`
|
|
1200
|
+
|
|
1201
|
+
`functions/sendMessage.serverless.ts` is a standalone `*.serverless.ts`. It declares a Schema-validated `input` (a bad payload → a typed `400` before your handler runs), reads its secret from `ctx.env`, and sends the email by POSTing to Resend's HTTP API with the framework-provided `HttpClient`. No `node:*` import — so the SAME file runs on a Cloudflare Worker:
|
|
1202
|
+
|
|
1203
|
+
```ts
|
|
1204
|
+
// functions/sendMessage.serverless.ts
|
|
1205
|
+
import { Effect, Schema } from 'effect'
|
|
1206
|
+
import { HttpClient, HttpClientRequest } from '@effect/platform'
|
|
1207
|
+
import { defineServerless, ServerlessHttpError } from '@voltro/serverless'
|
|
1208
|
+
|
|
1209
|
+
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
|
|
1210
|
+
|
|
1211
|
+
export default defineServerless({
|
|
1212
|
+
name: 'send-message',
|
|
1213
|
+
method: 'POST',
|
|
1214
|
+
input: Schema.Struct({
|
|
1215
|
+
name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(120)),
|
|
1216
|
+
email: Schema.String.pipe(Schema.pattern(EMAIL_RE)),
|
|
1217
|
+
message: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(5000)),
|
|
1218
|
+
}),
|
|
1219
|
+
output: Schema.Struct({ ok: Schema.Boolean }),
|
|
1220
|
+
runtime: { memoryMb: 128, timeoutSeconds: 10 },
|
|
1221
|
+
handler: ({ name, email, message }, ctx) =>
|
|
1222
|
+
Effect.gen(function* () {
|
|
1223
|
+
const apiKey = ctx.env.RESEND_API_KEY
|
|
1224
|
+
const to = ctx.env.CONTACT_TO ?? 'you@example.com'
|
|
1225
|
+
const from = ctx.env.CONTACT_FROM ?? 'Contact form <onboarding@resend.dev>'
|
|
1226
|
+
|
|
1227
|
+
// No key wired yet → fail with a clear 503 the form can render. The
|
|
1228
|
+
// function never pretends to send: honest in dev, honest in prod.
|
|
1229
|
+
if (!apiKey) {
|
|
1230
|
+
return yield* Effect.fail(
|
|
1231
|
+
new ServerlessHttpError({
|
|
1232
|
+
status: 503,
|
|
1233
|
+
message: 'email-not-configured',
|
|
1234
|
+
detail: 'Set RESEND_API_KEY (+ optionally CONTACT_TO / CONTACT_FROM) in the function env.',
|
|
1235
|
+
}),
|
|
1236
|
+
)
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
const http = yield* HttpClient.HttpClient
|
|
1240
|
+
const request = HttpClientRequest.post('https://api.resend.com/emails').pipe(
|
|
1241
|
+
HttpClientRequest.setHeader('authorization', `Bearer ${apiKey}`),
|
|
1242
|
+
HttpClientRequest.bodyUnsafeJson({
|
|
1243
|
+
from,
|
|
1244
|
+
to: [to],
|
|
1245
|
+
reply_to: email,
|
|
1246
|
+
subject: `New message from ${name}`,
|
|
1247
|
+
text: `${message}\n\n— ${name} <${email}>`,
|
|
1248
|
+
}),
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
const response = yield* http.execute(request)
|
|
1252
|
+
if (response.status >= 400) {
|
|
1253
|
+
const detail = yield* response.text
|
|
1254
|
+
return yield* Effect.fail(new ServerlessHttpError({ status: 502, message: 'email-send-failed', detail }))
|
|
1255
|
+
}
|
|
1256
|
+
return { ok: true }
|
|
1257
|
+
}),
|
|
1258
|
+
})
|
|
1259
|
+
```
|
|
1260
|
+
|
|
1261
|
+
`ServerlessHttpError({ status, message, detail })` is how you control the HTTP status the form sees — `503` when no key is configured, `502` when Resend rejects the send. The handler `Effect` declares `HttpClient` as its only dependency; the runner provides it.
|
|
1262
|
+
|
|
1263
|
+
## Run both halves locally (two terminals)
|
|
1264
|
+
|
|
1265
|
+
The page and the function run as two separate dev servers:
|
|
1266
|
+
|
|
1267
|
+
```bash
|
|
1268
|
+
pnpm install
|
|
1269
|
+
|
|
1270
|
+
# Terminal 1 — the static site (HMR dev server)
|
|
1271
|
+
pnpm --filter @acme/web dev # voltro dev .
|
|
1272
|
+
|
|
1273
|
+
# Terminal 2 — the serverless function on http://localhost:8910
|
|
1274
|
+
pnpm --filter @acme/web fn:dev # voltro serverless dev functions/sendMessage.serverless.ts
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
The form POSTs to `http://localhost:8910` (the `CONTACT_ENDPOINT` default). The cross-origin POST works because **`voltro serverless dev` sends CORS headers** — the same headers Cloudflare / Scaleway add at the edge, so dev matches prod.
|
|
1278
|
+
|
|
1279
|
+
Without `RESEND_API_KEY` the function returns a clean `503 email-not-configured` and the form renders it — it never pretends to send. To send for real, pass the secret to the function's env:
|
|
1280
|
+
|
|
1281
|
+
```bash
|
|
1282
|
+
RESEND_API_KEY=re_... CONTACT_TO=you@example.com \
|
|
1283
|
+
pnpm --filter @acme/web fn:dev
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
## Deploy — the split
|
|
1287
|
+
|
|
1288
|
+
The page and the function ship to **different** places — that's the headline:
|
|
1289
|
+
|
|
1290
|
+
```bash
|
|
1291
|
+
# 1. The static page → a CDN
|
|
1292
|
+
pnpm --filter @acme/web build
|
|
1293
|
+
voltro static deploy --host cloudflare-pages --project-name web
|
|
1294
|
+
|
|
1295
|
+
# 2. The function → a function host (scales to zero), deployed SEPARATELY
|
|
1296
|
+
voltro serverless deploy --target node # the package's fn:deploy script
|
|
1297
|
+
# ...or --target cloudflare, or --target scaleway
|
|
1298
|
+
```
|
|
1299
|
+
|
|
1300
|
+
Then point the form at the deployed function: set `CONTACT_ENDPOINT` in `src/config.ts` to the URL `voltro serverless deploy` printed, and rebuild the page. The function host bills per invocation and idles to zero between requests — no always-on server for an inbox form that fires a few times a day.
|
|
1301
|
+
|
|
1302
|
+
## When to use frontend-contact vs. the other web templates
|
|
1303
|
+
|
|
1304
|
+
| You want… | Pick |
|
|
1305
|
+
|---|---|
|
|
1306
|
+
| A static page + ONE serverless backend endpoint (a form, a small action) | `frontend-contact` |
|
|
1307
|
+
| A pure static surface with no backend at all | [`frontend-blank`](/docs/templates/blank) |
|
|
1308
|
+
| A heavily-interactive standalone tool whose state lives in the browser | [`frontend-spa`](/docs/templates/spa) |
|
|
1309
|
+
| A marketing landing surface | [`frontend-landing`](/docs/templates/landing) |
|
|
1310
|
+
| A documentation site | [`frontend-docs`](/docs/templates/docs) |
|
|
1311
|
+
|
|
1312
|
+
## Pairs well with
|
|
1313
|
+
|
|
1314
|
+
- [Serverless functions](/docs/deployment/serverless-functions) — the deploy + runtime model for the `*.serverless.ts` half (node / Cloudflare / Scaleway, scales to zero).
|
|
1315
|
+
- [Static sites](/docs/deployment/static-sites) — where the pre-rendered page goes (any CDN).
|
|
1316
|
+
- [Edge functions](/docs/templates/edge-functions) — when you need MORE than one function, or richer routing than a single contact endpoint.
|
|
1317
|
+
- [Render modes](/docs/routing/render-modes) — the `static` + `islands` combination the page leans on.
|
|
1318
|
+
|
|
1319
|
+
## Anti-patterns
|
|
1320
|
+
|
|
1321
|
+
- **Turning the function into a long-running api.** A `*.serverless.ts` is a standalone, scale-to-zero artifact — bundled + deployed on its own with `voltro serverless`, not discovered by `voltro dev` as part of an api. If you find yourself wanting queries, subscriptions, or a database next to it, you want an api template, not this.
|
|
1322
|
+
- **Reaching for `node:*` in the function.** The handler is edge-safe by design (it runs on a Cloudflare Worker). Use the framework-provided `HttpClient` for outbound HTTP; a `node:`-prefixed import breaks the Cloudflare/Scaleway targets.
|
|
1323
|
+
- **Making the function pretend to send when no key is wired.** The template fails LOUD with a `503 email-not-configured` that the form renders. Don't swap that for a silent `{ ok: true }` — a contact form that silently drops messages is worse than one that says it's not configured.
|
|
1324
|
+
- **Letting the form go `interactive: 'full'` or `'none'`.** The page is mostly static copy with one interactive widget — that's exactly `interactive: 'islands'` + an `island()` form. `'full'` hydrates the whole tree for no reason; `'none'` would strip the JS the form needs.
|
|
1325
|
+
- **Skipping the input Schema on the function.** The `defineServerless` `input` is what turns a malformed POST into a typed `400` before your handler runs. Don't hand-parse `JSON.parse(body)` and trust it.
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
---
|
|
1330
|
+
|
|
1331
|
+
<!-- source: en/templates/app.md -->
|
|
1332
|
+
## Fullstack app
|
|
1333
|
+
|
|
1334
|
+
_The reactive end-to-end loop in one page — a web frontend wired to an api. A live list (useSubscription) + a create form (useMutation) with zero-boilerplate auto-optimistic updates._
|
|
1335
|
+
|
|
1336
|
+
The framework's headline feature — a web client subscribing to a server query and getting WebSocket deltas on every write — in one page. `frontend-app` is the **only** template that wires a web frontend to an api; every other `frontend-*` template is backend-less and every `api-*` template is frontend-less. Template id: **`frontend-app`**.
|
|
1337
|
+
|
|
1338
|
+
## Scaffold the pair
|
|
1339
|
+
|
|
1340
|
+
`frontend-app` consumes a sibling api, so scaffold it **together with** [`api-backend`](/docs/templates/api-backend) — which exposes the `notes.list` query + `notes.create` mutation this page uses:
|
|
1341
|
+
|
|
1342
|
+
```bash
|
|
1343
|
+
voltro create-project demo --api=api-backend --web=frontend-app
|
|
1344
|
+
cd demo
|
|
1345
|
+
pnpm install
|
|
1346
|
+
pnpm dev # boots BOTH apps — the api and the web
|
|
1347
|
+
```
|
|
1348
|
+
|
|
1349
|
+
Add a note and it appears in the list instantly — no refetch, no polling. Open a second tab: a note added in one shows up live in the other.
|
|
1350
|
+
|
|
1351
|
+
## What ships
|
|
1352
|
+
|
|
1353
|
+
```text
|
|
1354
|
+
apps/demo/web/
|
|
1355
|
+
├── app.config.ts # type:web + the apis: { app } wiring
|
|
1356
|
+
├── package.json # depends on @demo/api (the sibling) + @voltro/client
|
|
1357
|
+
├── tsconfig.json
|
|
1358
|
+
├── README.md
|
|
1359
|
+
└── src/pages/
|
|
1360
|
+
├── layout.tsx # root shell (header + <main>)
|
|
1361
|
+
└── index.tsx # the reactive page — useSubscription + useMutation
|
|
1362
|
+
```
|
|
1363
|
+
|
|
1364
|
+
## The `apis` wiring
|
|
1365
|
+
|
|
1366
|
+
A web app declares the apis it consumes in `app.config.ts`. The map KEY is the lookup name every hook takes as its first argument:
|
|
1367
|
+
|
|
1368
|
+
```ts
|
|
1369
|
+
// app.config.ts
|
|
1370
|
+
export default {
|
|
1371
|
+
type: 'web' as const,
|
|
1372
|
+
name: 'DemoWeb',
|
|
1373
|
+
apis: {
|
|
1374
|
+
app: { package: '@demo/api' }, // the sibling api in this project
|
|
1375
|
+
},
|
|
1376
|
+
}
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
The `package` resolves the api workspace package; its port is auto-discovered from its own `app.config.ts`, and codegen pulls its typed rpc surface so the hooks are end-to-end typed. Add more entries to consume more apis — each gets its own WebSocket + reconnect cycle, independent of the others.
|
|
1380
|
+
|
|
1381
|
+
```ts
|
|
1382
|
+
// package.json — the web depends on the sibling api as a workspace package
|
|
1383
|
+
"dependencies": {
|
|
1384
|
+
"@demo/api": "workspace:*",
|
|
1385
|
+
"@voltro/client": "workspace:*",
|
|
1386
|
+
"@voltro/web": "workspace:*"
|
|
1387
|
+
}
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1390
|
+
## The reactive loop
|
|
1391
|
+
|
|
1392
|
+
```tsx
|
|
1393
|
+
// src/pages/index.tsx
|
|
1394
|
+
import { useMutation, useSubscription } from '@voltro/client'
|
|
1395
|
+
|
|
1396
|
+
const TENANT = 'acme'
|
|
1397
|
+
|
|
1398
|
+
const IndexPage = () => {
|
|
1399
|
+
// ONE live WebSocket subscription. `data` re-renders whenever the `notes`
|
|
1400
|
+
// table changes — from this tab, another tab, a workflow, or a raw DB write.
|
|
1401
|
+
const { data, revision, error, pendingPatches } =
|
|
1402
|
+
useSubscription<ReadonlyArray<Note>>('app', 'notes.list')
|
|
1403
|
+
|
|
1404
|
+
// Invokes the mutation. Its descriptor declares `target: { table:'notes',
|
|
1405
|
+
// op:'insert' }`, so the framework AUTO-PREPENDS an optimistic row the
|
|
1406
|
+
// instant you submit — replaced by the real row (or reverted) on resolve.
|
|
1407
|
+
const create = useMutation<{ tenantId: string; title: string; body: string }>('app', 'notes.create')
|
|
1408
|
+
|
|
1409
|
+
const notes = data ?? []
|
|
1410
|
+
// … a form that calls create.mutate({ tenantId: TENANT, title, body }) …
|
|
1411
|
+
}
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
1. **`useSubscription('app', 'notes.list')`** opens the live feed. The first argument (`'app'`) is the api NAME from `app.config.ts`; the second is the rpc tag. Two components subscribing with the same triple share ONE upstream subscription.
|
|
1415
|
+
2. **`useMutation('app', 'notes.create').mutate({...})`** writes. There is no `.withOptimistic`, no `useOptimistic`, no `startTransition` — the auto-optimistic patch comes from the mutation's server-side `target`. Optimistic rows carry `optimistic: true`; render them faintly until the server delta supersedes them.
|
|
1416
|
+
|
|
1417
|
+
The subscription builder also exposes `revision`, `emittedAt`, `error`, and `pendingPatches` for status UI; the mutation builder exposes `pending`, `error`, and `data`.
|
|
1418
|
+
|
|
1419
|
+
## Pairing with a different api
|
|
1420
|
+
|
|
1421
|
+
This page is wired to api-backend's `notes` domain. To point it at your own api, change `apis.app.package` in `app.config.ts`, then swap the rpc tags + the row type in `src/pages/index.tsx` to match your descriptors. The hook shapes are identical for any api.
|
|
1422
|
+
|
|
1423
|
+
## When to use
|
|
1424
|
+
|
|
1425
|
+
| You want… | Pick |
|
|
1426
|
+
|---|---|
|
|
1427
|
+
| To see the reactive loop / start a real fullstack app | `frontend-app` (+ `api-backend`) |
|
|
1428
|
+
| A frontend with no backend | [`frontend-blank`](/docs/templates/blank) / [`frontend-landing`](/docs/templates/landing) |
|
|
1429
|
+
| Just the backend | [`api-backend`](/docs/templates/api-backend) |
|
|
1430
|
+
|
|
1431
|
+
## Anti-patterns
|
|
1432
|
+
|
|
1433
|
+
- **Scaffolding `frontend-app` without an api.** It depends on the sibling `@<project>/api` workspace package — `pnpm install` fails without it. Always scaffold the pair (`--api=api-backend --web=frontend-app`).
|
|
1434
|
+
- **Polling / `refetch`.** This framework is push-based; the subscription stays live for the component's lifetime. If you reach for `setInterval(() => refetch())`, something is wrong with the subscription.
|
|
1435
|
+
- **Hand-rolling optimistic updates.** The `target` on the mutation descriptor drives the patch automatically. Only reach for `.withOptimistic` when the default insert/update/delete shape isn't what you want.
|
|
1436
|
+
- **Trusting `x-tenant` in production.** The dev `AuthMiddleware` reads it unauthenticated. Wire a real resolver before shipping (see the [authentication docs](/docs/authentication/overview)).
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
---
|
|
1441
|
+
|
|
1442
|
+
<!-- source: en/templates/i18n.md -->
|
|
1443
|
+
## i18n
|
|
1444
|
+
|
|
1445
|
+
_A bilingual (en/de) static site using the URL-prefix i18n strategy — type-checked translation catalogs, [locale] mirror pages, and per-locale SSG so each language is its own crawlable URL with a translated title. No backend._
|
|
1446
|
+
|
|
1447
|
+
A bilingual static site built the **URL-prefix** way: the default locale (English) lives at the bare path (`/`, `/about`), and every other locale is prefixed (`/de`, `/de/about`). Each translated page is its own crawlable URL with its own pre-rendered `<title>` — the SEO-friendly shape for docs, marketing, and any content where the URL *is* the product. Translations are type-checked against a base catalog so they can never silently drift, and `voltro build` emits one HTML file per (page × locale). No api, no rpc client, no SSR. Template id: **`frontend-i18n`**.
|
|
1448
|
+
|
|
1449
|
+
## Scaffold
|
|
1450
|
+
|
|
1451
|
+
```bash
|
|
1452
|
+
voltro create-project acme --web=frontend-i18n
|
|
1453
|
+
voltro add-app site --template=frontend-i18n --to acme
|
|
1454
|
+
```
|
|
1455
|
+
|
|
1456
|
+
## What ships
|
|
1457
|
+
|
|
1458
|
+
```text
|
|
1459
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
1460
|
+
├── app.config.ts # type:web, theme:'system', locales:['en','de']
|
|
1461
|
+
├── package.json # + @voltro/i18n
|
|
1462
|
+
├── tsconfig.json
|
|
1463
|
+
├── README.md
|
|
1464
|
+
└── src/
|
|
1465
|
+
├── globals.css
|
|
1466
|
+
├── globals.d.ts # `declare module '*.css'`
|
|
1467
|
+
├── lib/
|
|
1468
|
+
│ └── locale.ts # SUPPORTED_LOCALES + URL-prefix helpers (single source of truth)
|
|
1469
|
+
├── locales/
|
|
1470
|
+
│ ├── en.ts # base catalog (source of truth)
|
|
1471
|
+
│ └── de.ts # defineLocale<typeof en>() → parity-enforced
|
|
1472
|
+
└── pages/
|
|
1473
|
+
├── layout.tsx # inner URL-driven <I18nProvider> + language switch
|
|
1474
|
+
├── index.tsx # home — renderMode 'static', meta as fn of {locale}
|
|
1475
|
+
├── about.tsx # second page
|
|
1476
|
+
└── [locale]/
|
|
1477
|
+
├── index.tsx # mirror → emits /de
|
|
1478
|
+
└── about.tsx # mirror → emits /de/about
|
|
1479
|
+
```
|
|
1480
|
+
|
|
1481
|
+
The app depends on `@voltro/web` / `@voltro/client` / `@voltro/env` / `@voltro/cli` + React 19 + **`@voltro/i18n`** — the only addition over the other web templates. No `apis:` entry; every page is `renderMode: 'static'`.
|
|
1482
|
+
|
|
1483
|
+
## The catalogs — type-checked parity
|
|
1484
|
+
|
|
1485
|
+
`src/locales/en.ts` is the base catalog and the source of truth. `src/locales/de.ts` runs through `defineLocale<typeof en>()`, a curried helper that **enforces exact key parity at the type level** — drop or add a key and `tsc --noEmit` fails. That's the whole point of the base-catalog pattern: translations can't silently fall out of sync.
|
|
1486
|
+
|
|
1487
|
+
```ts
|
|
1488
|
+
// src/locales/en.ts — base catalog
|
|
1489
|
+
import { defineCatalog } from '@voltro/i18n'
|
|
1490
|
+
|
|
1491
|
+
export default defineCatalog({
|
|
1492
|
+
'nav.home': 'Home',
|
|
1493
|
+
'home.title': 'Hello, {name}',
|
|
1494
|
+
'home.tagline': 'A bilingual static site built with Voltro.',
|
|
1495
|
+
// …
|
|
1496
|
+
} as const)
|
|
1497
|
+
```
|
|
1498
|
+
|
|
1499
|
+
```ts
|
|
1500
|
+
// src/locales/de.ts — MUST mirror en key-for-key, or tsc fails
|
|
1501
|
+
import { defineLocale } from '@voltro/i18n'
|
|
1502
|
+
import en from './en'
|
|
1503
|
+
|
|
1504
|
+
export default defineLocale<typeof en>()({
|
|
1505
|
+
'nav.home': 'Start',
|
|
1506
|
+
'home.title': 'Hallo, {name}',
|
|
1507
|
+
'home.tagline': 'Eine zweisprachige statische Website, gebaut mit Voltro.',
|
|
1508
|
+
// …
|
|
1509
|
+
})
|
|
1510
|
+
```
|
|
1511
|
+
|
|
1512
|
+
Keep ICU MessageFormat placeholders (`{name}`, `{count, plural, …}`) identical across locales — react-intl validates them at render time. Read them in components with `<T id="…" />` (JSX) or `useT('…')` (imperative).
|
|
1513
|
+
|
|
1514
|
+
## URL-prefix routing — `lib/locale.ts` + `[locale]` mirrors
|
|
1515
|
+
|
|
1516
|
+
The framework's auto-wired `<I18nProvider>` is *cookie*-driven (Strategy A — product dashboards). URL-prefix routing (Strategy B) needs the active locale to come from the URL instead, so `src/lib/locale.ts` is the single source of truth: it lists the supported locales (kept in sync with `app.config.ts`) and the prefix helpers.
|
|
1517
|
+
|
|
1518
|
+
```ts
|
|
1519
|
+
// src/lib/locale.ts (excerpt)
|
|
1520
|
+
import { useLocation } from '@voltro/web'
|
|
1521
|
+
|
|
1522
|
+
export const SUPPORTED_LOCALES = ['en', 'de'] as const
|
|
1523
|
+
export const DEFAULT_LOCALE = 'en'
|
|
1524
|
+
|
|
1525
|
+
// '/de/about' → 'de'; '/about' → 'en' (default lives at the bare path)
|
|
1526
|
+
export const useUrlLocale = (): Locale => localeFromPathname(useLocation())
|
|
1527
|
+
|
|
1528
|
+
// '/about' + 'de' → '/de/about'; the default locale stays bare ('/about')
|
|
1529
|
+
export const withLocalePrefix = (path: string, locale: string): string =>
|
|
1530
|
+
locale === DEFAULT_LOCALE ? path : path === '/' ? `/${locale}` : `/${locale}${path}`
|
|
1531
|
+
```
|
|
1532
|
+
|
|
1533
|
+
Each page has a one-line **mirror** under `src/pages/[locale]/`. The mirror re-exports the bare page verbatim (the component reads its locale from the URL via the layout's provider) and uses `getStaticPaths` to enumerate the *non-default* locales — which is what tells `voltro build` to emit the `/de/...` HTML. Without the mirror, only the default-locale URL is built.
|
|
1534
|
+
|
|
1535
|
+
```tsx
|
|
1536
|
+
// src/pages/[locale]/index.tsx — the entire file
|
|
1537
|
+
import { SUPPORTED_LOCALES, DEFAULT_LOCALE } from '../../lib/locale'
|
|
1538
|
+
|
|
1539
|
+
export { default } from '../index'
|
|
1540
|
+
export { renderMode, meta } from '../index'
|
|
1541
|
+
|
|
1542
|
+
export const getStaticPaths = async (): Promise<Array<{ params: { locale: string } }>> =>
|
|
1543
|
+
SUPPORTED_LOCALES
|
|
1544
|
+
.filter((l) => l !== DEFAULT_LOCALE)
|
|
1545
|
+
.map((locale) => ({ params: { locale } }))
|
|
1546
|
+
```
|
|
1547
|
+
|
|
1548
|
+
## Per-locale `<title>` — `meta` as a function of `{ locale }`
|
|
1549
|
+
|
|
1550
|
+
The body of each page is localised automatically (the SSG pipeline wraps every variant in the right catalog's provider). The document head is NOT — unless you export `meta` as a **function of `{ locale }`**. The framework drives `locale` from `params.locale` on the `[locale]` route (and `defaultLocale` on the bare path), so each variant bakes the right `<title>` / `<meta description>` into its HTML — exactly what you want for SEO and social cards. A plain `meta` object would leave the head English on `/de`.
|
|
1551
|
+
|
|
1552
|
+
```tsx
|
|
1553
|
+
// src/pages/index.tsx
|
|
1554
|
+
import { T, useT, useLocale } from '@voltro/i18n'
|
|
1555
|
+
import { getCatalog } from '../lib/locale'
|
|
1556
|
+
|
|
1557
|
+
export const renderMode = 'static' as const
|
|
1558
|
+
|
|
1559
|
+
export const meta = ({ locale }: { locale: string }) => {
|
|
1560
|
+
const c = getCatalog(locale) // the catalog for this locale, at build time
|
|
1561
|
+
return { title: c['meta.home.title'], description: c['meta.home.description'] }
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
export default function Index() {
|
|
1565
|
+
const greeting = useT('home.title', { name: 'Voltro' }) // imperative form
|
|
1566
|
+
return (
|
|
1567
|
+
<article>
|
|
1568
|
+
<h1>{greeting}</h1>
|
|
1569
|
+
<p><T id="home.tagline" /></p> {/* JSX form */}
|
|
1570
|
+
<p><T id="home.activeLocale" values={{ locale: useLocale().toUpperCase() }} /></p>
|
|
1571
|
+
</article>
|
|
1572
|
+
)
|
|
1573
|
+
}
|
|
1574
|
+
```
|
|
1575
|
+
|
|
1576
|
+
## The layout — inner URL-driven provider + language switch
|
|
1577
|
+
|
|
1578
|
+
`src/pages/layout.tsx` wraps every page in an **inner** `<I18nProvider>` whose locale comes from `useUrlLocale()`. Because `useLocation` re-renders on client-side navigation, moving between `/about` and `/de/about` swaps the catalog without a full reload; at build time it resolves to the same locale the SSG pipeline pre-rendered. The language switch is a set of plain `<a>` links — one per locale, pointing at the current path under each prefix.
|
|
1579
|
+
|
|
1580
|
+
```tsx
|
|
1581
|
+
// src/pages/layout.tsx (excerpt)
|
|
1582
|
+
import { I18nProvider, T } from '@voltro/i18n'
|
|
1583
|
+
import { useLocation } from '@voltro/web'
|
|
1584
|
+
import { CATALOGS, DEFAULT_LOCALE, SUPPORTED_LOCALES, stripLocalePrefix, useUrlLocale, withLocalePrefix } from '../lib/locale'
|
|
1585
|
+
|
|
1586
|
+
export default function Layout({ children }) {
|
|
1587
|
+
const locale = useUrlLocale()
|
|
1588
|
+
const barePath = stripLocalePrefix(useLocation()) // keep the page when switching language
|
|
1589
|
+
return (
|
|
1590
|
+
<I18nProvider locale={locale} messages={CATALOGS[locale]} defaultLocale={DEFAULT_LOCALE}>
|
|
1591
|
+
<nav>
|
|
1592
|
+
{SUPPORTED_LOCALES.map((code) => (
|
|
1593
|
+
<a key={code} href={withLocalePrefix(barePath, code)} aria-current={code === locale ? 'true' : undefined}>
|
|
1594
|
+
{code.toUpperCase()}
|
|
1595
|
+
</a>
|
|
1596
|
+
))}
|
|
1597
|
+
</nav>
|
|
1598
|
+
<main>{children}</main>
|
|
1599
|
+
</I18nProvider>
|
|
1600
|
+
)
|
|
1601
|
+
}
|
|
1602
|
+
```
|
|
1603
|
+
|
|
1604
|
+
The nav uses plain `<a>` (not `<Link>`) on purpose: the hrefs are *computed* per locale, and `<Link to>` wants a statically-known route URL. The Router's global click interceptor SPA-navigates internal `<a>` anyway, so the inner provider still swaps catalogs without a reload.
|
|
1605
|
+
|
|
1606
|
+
## Config — `locales` + `defaultLocale`
|
|
1607
|
+
|
|
1608
|
+
`locales` in `app.config.ts` is load-bearing: it tells `voltro build` to pre-render a per-locale variant for every `[locale]/...` mirror, each wrapped in the right catalog's provider. Every code listed MUST have a matching `src/locales/<code>.ts`, and `defaultLocale` MUST be one of `locales`. Keep these two fields in sync with `SUPPORTED_LOCALES` / `DEFAULT_LOCALE` in `src/lib/locale.ts`.
|
|
1609
|
+
|
|
1610
|
+
```ts
|
|
1611
|
+
// app.config.ts
|
|
1612
|
+
export default {
|
|
1613
|
+
type: 'web' as const,
|
|
1614
|
+
name: 'AcmeSite',
|
|
1615
|
+
port: 5173,
|
|
1616
|
+
theme: 'system' as const,
|
|
1617
|
+
locales: ['en', 'de'] as const,
|
|
1618
|
+
defaultLocale: 'en' as const,
|
|
1619
|
+
env,
|
|
1620
|
+
}
|
|
1621
|
+
```
|
|
1622
|
+
|
|
1623
|
+
## Build + serve
|
|
1624
|
+
|
|
1625
|
+
```bash
|
|
1626
|
+
voltro build . # pre-render → one HTML file per (page × locale)
|
|
1627
|
+
voltro start . # serve the built output locally
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
The build emits the URL-prefixed tree:
|
|
1631
|
+
|
|
1632
|
+
```text
|
|
1633
|
+
dist/index.html ← en (default, bare path)
|
|
1634
|
+
dist/about/index.html ← en
|
|
1635
|
+
dist/de/index.html ← de mirror
|
|
1636
|
+
dist/de/about/index.html ← de mirror
|
|
1637
|
+
```
|
|
1638
|
+
|
|
1639
|
+
Each variant ships its localised body AND the right per-locale `<title>` / `<meta description>`. The output is pure static files — push it to any CDN.
|
|
1640
|
+
|
|
1641
|
+
## Add a locale
|
|
1642
|
+
|
|
1643
|
+
1. Add the code to `SUPPORTED_LOCALES` in `src/lib/locale.ts` AND to `locales` in `app.config.ts`.
|
|
1644
|
+
2. Add `src/locales/<code>.ts` (mirror `en.ts`; the type-check enforces parity), and add it to `CATALOGS` in `src/lib/locale.ts`.
|
|
1645
|
+
|
|
1646
|
+
The `[locale]` mirrors pick it up automatically — `getStaticPaths` reads `SUPPORTED_LOCALES`.
|
|
1647
|
+
|
|
1648
|
+
## When to use frontend-i18n vs. the other web templates
|
|
1649
|
+
|
|
1650
|
+
| You want… | Pick |
|
|
1651
|
+
|---|---|
|
|
1652
|
+
| A multilingual content/marketing site where each language is its own crawlable URL | `frontend-i18n` |
|
|
1653
|
+
| A single-language content site pre-rendered to flat HTML | [`frontend-static-blog`](/docs/templates/static-blog) |
|
|
1654
|
+
| A documentation site | [`frontend-docs`](/docs/templates/docs) |
|
|
1655
|
+
| A blank React shell to bring your own brand | [`frontend-blank`](/docs/templates/blank) |
|
|
1656
|
+
| A product app where all locales share one URL (cookie-driven) | any web template + `locales` in `app.config.ts` (Strategy A) |
|
|
1657
|
+
|
|
1658
|
+
## Pairs well with
|
|
1659
|
+
|
|
1660
|
+
- **Nothing on the backend** — this is a self-contained static site. Add an api later (and an `apis:` entry) only when a page needs live, request-time data, then flip that page to `renderMode: 'ssr' | 'isr'`.
|
|
1661
|
+
- The **cookie-only** i18n strategy (Strategy A) for sibling *product* apps where the URL shouldn't encode the language — same catalogs, no `[locale]` mirrors. See [URL strategies](/docs/i18n/url-strategies) for both.
|
|
1662
|
+
|
|
1663
|
+
## Anti-patterns
|
|
1664
|
+
|
|
1665
|
+
- **Forgetting the `[locale]` mirror for a new page.** Create `src/pages/foo.tsx` AND a one-line mirror `src/pages/[locale]/foo.tsx`. Without the mirror, only the default-locale URL is emitted — `/de/foo` 404s.
|
|
1666
|
+
- **Exporting `meta` as a plain object.** The body localises, but the `<title>` / `<meta description>` stay English on `/de`. Export `meta` as a function of `{ locale }` and read the catalog via `getCatalog(locale)`.
|
|
1667
|
+
- **Skipping `defineLocale<typeof en>()` for a non-base catalog.** Plain `defineCatalog(...)` compiles but loses the parity check — the one feature that stops translations drifting. Always run non-base locales through `defineLocale<typeof en>()`.
|
|
1668
|
+
- **Angle brackets in catalog strings.** react-intl's ICU parser treats `<tag>` as a rich-text element and fails (`FORMAT_ERROR`) if there's no matching close tag. Write "page titles", not "`<title>` tags", in a message.
|
|
1669
|
+
- **Reading the cookie directly in a component.** Use `useT` / `useLocale` — the active locale is React state inside the provider, not browser state. Reaching for `document.cookie` re-introduces the hydration flash the framework is designed to avoid.
|
|
1670
|
+
|
|
1671
|
+
|
|
1672
|
+
|
|
1673
|
+
---
|
|
1674
|
+
|
|
1675
|
+
<!-- source: en/templates/dashboard.md -->
|
|
1676
|
+
## Dashboard
|
|
1677
|
+
|
|
1678
|
+
_An authenticated dashboard shell — a silent (marketing) route group for public pages, a /dashboard area gated by a nested layout loader that RedirectErrors to /login when there's no session, and scoped error/loading/not-found special files. SSR auth gate, no backend._
|
|
1679
|
+
|
|
1680
|
+
The app shell every SaaS frontend needs: a public marketing area and an authenticated dashboard, with the auth gate done right. Public pages live in a **silent route group** (`(marketing)/` — organises files + scopes a layout without adding a URL segment). The `/dashboard` area is gated by a **nested layout loader** that runs once for every page beneath it, server-side, and `throw`s `RedirectError('/login')` when there's no session — so an unauthenticated visitor is bounced before any dashboard HTML renders. The authed pages are `renderMode: 'ssr'` precisely so the gate runs per request. The gate is a cookie-only stand-in, so the template boots with **no backend**. Template id: **`frontend-dashboard`**.
|
|
1681
|
+
|
|
1682
|
+
## Scaffold
|
|
1683
|
+
|
|
1684
|
+
```bash
|
|
1685
|
+
voltro create-project acme --web=frontend-dashboard
|
|
1686
|
+
voltro add-app app --template=frontend-dashboard --to acme
|
|
1687
|
+
```
|
|
1688
|
+
|
|
1689
|
+
## What ships
|
|
1690
|
+
|
|
1691
|
+
```text
|
|
1692
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
1693
|
+
├── app.config.ts # type:web, theme:'system', defineEnv (public-only)
|
|
1694
|
+
├── package.json
|
|
1695
|
+
├── tsconfig.json
|
|
1696
|
+
├── README.md
|
|
1697
|
+
└── src/
|
|
1698
|
+
├── globals.css
|
|
1699
|
+
├── globals.d.ts # `declare module '*.css'`
|
|
1700
|
+
├── config.ts # APP_NAME brand constant
|
|
1701
|
+
├── lib/
|
|
1702
|
+
│ └── auth.ts # the cookie-only demo gate (readCookie + isSignedIn)
|
|
1703
|
+
└── pages/
|
|
1704
|
+
├── layout.tsx # thin root layout (loads the stylesheet)
|
|
1705
|
+
├── (marketing)/ # SILENT route group → URLs have NO group segment
|
|
1706
|
+
│ ├── layout.tsx # marketing header/footer chrome
|
|
1707
|
+
│ ├── index.tsx # / — public landing (static)
|
|
1708
|
+
│ └── login.tsx # /login — sets the demo session cookie
|
|
1709
|
+
└── dashboard/ # real /dashboard prefix
|
|
1710
|
+
├── layout.tsx # THE AUTH GATE — loader + RedirectError + shared data
|
|
1711
|
+
├── error.tsx # scoped error boundary
|
|
1712
|
+
├── loading.tsx # scoped opt-in skeleton
|
|
1713
|
+
├── not-found.tsx # scoped 404
|
|
1714
|
+
├── index.tsx # /dashboard — overview (ssr)
|
|
1715
|
+
└── settings.tsx # /dashboard/settings — settings (ssr)
|
|
1716
|
+
```
|
|
1717
|
+
|
|
1718
|
+
The app depends only on `@voltro/web` / `@voltro/client` / `@voltro/env` / `@voltro/cli` + React 19 — no api, no rpc client.
|
|
1719
|
+
|
|
1720
|
+
## Two route shapes — silent group vs real prefix
|
|
1721
|
+
|
|
1722
|
+
`src/pages/(marketing)/` is a **route group**: the parentheses organise files and scope a layout, but the directory name **never appears in a URL**. So the pages inside are `/` and `/login` — not `/(marketing)/...`. Use a group when you want a shared layout (here: the marketing header) for a set of pages without inventing a URL segment for it.
|
|
1723
|
+
|
|
1724
|
+
`src/pages/dashboard/` is an ordinary directory, so it DOES contribute a URL segment — `/dashboard`, `/dashboard/settings`. The contrast is the point: a group for cross-cutting layout scoping, a real dir when you want the prefix.
|
|
1725
|
+
|
|
1726
|
+
## The auth gate — a nested layout loader
|
|
1727
|
+
|
|
1728
|
+
A `layout.tsx` can export a `loader`, just like a page. The framework runs it once for every page beneath that directory, **in parallel with the page loader, server-side, before render**. That makes the dashboard layout's loader the one right place to validate the session and preload shared data:
|
|
1729
|
+
|
|
1730
|
+
```tsx
|
|
1731
|
+
// src/pages/dashboard/layout.tsx
|
|
1732
|
+
import { RedirectError, useLoaderData, type LoaderFn } from '@voltro/web'
|
|
1733
|
+
import { isSignedIn } from '../../lib/auth'
|
|
1734
|
+
|
|
1735
|
+
interface DashboardData {
|
|
1736
|
+
readonly user: { readonly name: string; readonly plan: string }
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
export const loader: LoaderFn<DashboardData> = async ({ headers }) => {
|
|
1740
|
+
if (!isSignedIn(headers)) {
|
|
1741
|
+
// No session → bounce to login, remembering where they were headed.
|
|
1742
|
+
throw new RedirectError('/login?from=/dashboard')
|
|
1743
|
+
}
|
|
1744
|
+
// Shared data, loaded ONCE for every page beneath this layout, read via
|
|
1745
|
+
// useLoaderData<DashboardData>() right here in the layout.
|
|
1746
|
+
return { user: { name: 'Demo User', plan: 'Pro' } }
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
export default function DashboardLayout({ children }) {
|
|
1750
|
+
const { user } = useLoaderData<DashboardData>()
|
|
1751
|
+
// … sidebar + topbar showing `user`, then {children} …
|
|
1752
|
+
}
|
|
1753
|
+
```
|
|
1754
|
+
|
|
1755
|
+
`RedirectError` is loader control-flow, NOT an error-boundary error: on SSR it emits a `303` + `Location`; on a client navigation it does `navigate(location, { replace: true })` so Back doesn't bounce onto the redirecting page. (Its sibling `NotFoundError` renders the scoped `not-found.tsx` instead.)
|
|
1756
|
+
|
|
1757
|
+
## Why the dashboard pages are `renderMode: 'ssr'`
|
|
1758
|
+
|
|
1759
|
+
The gate must run **per request** — it reads the request's cookie and may redirect. A `static` page is pre-rendered once at build time with no per-visitor render, so it can't redirect per-visitor; in fact `voltro build` **rejects** a `RedirectError` thrown during static pre-render. So the authed pages declare `renderMode: 'ssr'`:
|
|
1760
|
+
|
|
1761
|
+
```tsx
|
|
1762
|
+
// src/pages/dashboard/index.tsx
|
|
1763
|
+
export const renderMode = 'ssr' as const
|
|
1764
|
+
export const meta = { title: 'Overview' }
|
|
1765
|
+
export default function Overview() { /* … */ }
|
|
1766
|
+
```
|
|
1767
|
+
|
|
1768
|
+
The public marketing pages stay `static` (pre-rendered, CDN-friendly) — only the gated area pays for SSR.
|
|
1769
|
+
|
|
1770
|
+
## The demo gate — cookie-only, zero backend
|
|
1771
|
+
|
|
1772
|
+
`src/lib/auth.ts` is a deliberate stand-in so the template boots with nothing wired. It reads a cookie from **both** places the gate runs — the request `Cookie` header on SSR, and `document.cookie` on a client navigation (where the loader's `headers` is undefined):
|
|
1773
|
+
|
|
1774
|
+
```ts
|
|
1775
|
+
// src/lib/auth.ts
|
|
1776
|
+
export const SESSION_COOKIE = 'demo_session'
|
|
1777
|
+
|
|
1778
|
+
export const isSignedIn = (headers: Record<string, string | undefined> | undefined): boolean => {
|
|
1779
|
+
const cookieHeader = headers?.['cookie'] ?? (typeof document === 'undefined' ? '' : document.cookie)
|
|
1780
|
+
return readCookie(cookieHeader, SESSION_COOKIE) !== undefined
|
|
1781
|
+
}
|
|
1782
|
+
```
|
|
1783
|
+
|
|
1784
|
+
`/login` writes that cookie (a real app POSTs credentials to an api that returns a signed, HttpOnly cookie). Swap `isSignedIn` for your real check — verify a signed session, or call your api — and the rest of the shell is unchanged.
|
|
1785
|
+
|
|
1786
|
+
## Scoped special files
|
|
1787
|
+
|
|
1788
|
+
Three files under `dashboard/` apply to every page in that subtree:
|
|
1789
|
+
|
|
1790
|
+
- **`error.tsx`** — an error boundary. A render error (or a loader rejection that ISN'T a `RedirectError` / `NotFoundError`) bubbles to the nearest `error.tsx` instead of the router default. `reset` re-runs the failed segment.
|
|
1791
|
+
- **`loading.tsx`** — an OPT-IN skeleton. By default navigation is deferred (the previous page stays until the loader settles); adding `loading.tsx` opts this subtree into swapping in immediately and showing a skeleton — which suits a data-backed dashboard.
|
|
1792
|
+
- **`not-found.tsx`** — a scoped 404. On a client navigation to an unmatched `/dashboard/...` URL the router renders this; a direct request to such a URL returns a `404`.
|
|
1793
|
+
|
|
1794
|
+
## Build + serve
|
|
1795
|
+
|
|
1796
|
+
```bash
|
|
1797
|
+
voltro build . # static marketing pages + an SSR bundle for /dashboard
|
|
1798
|
+
voltro start . # serve: / + /login are static; /dashboard renders per request
|
|
1799
|
+
```
|
|
1800
|
+
|
|
1801
|
+
`voltro start` returns `303 → /login?from=/dashboard` for `/dashboard` without a session cookie, and `200` once the cookie is set — the gate in action.
|
|
1802
|
+
|
|
1803
|
+
## Wire a real backend
|
|
1804
|
+
|
|
1805
|
+
Add an api and an `apis:` entry in `app.config.ts`, then read live data in the dashboard pages with `useSubscription`. The gate stays the same — only its session check moves from the demo cookie to your real session. For the gate's own session verification, see the [Sessions guide](/docs/authentication/sessions).
|
|
1806
|
+
|
|
1807
|
+
## When to use frontend-dashboard vs. the other web templates
|
|
1808
|
+
|
|
1809
|
+
| You want… | Pick |
|
|
1810
|
+
|---|---|
|
|
1811
|
+
| An authenticated app shell — public pages + a gated dashboard | `frontend-dashboard` |
|
|
1812
|
+
| The reactive end-to-end loop (frontend wired to an api) | [`frontend-app`](/docs/templates/app) |
|
|
1813
|
+
| Server-rendered / cached pages without an auth gate | [`frontend-ssr`](/docs/templates/ssr) |
|
|
1814
|
+
| A blank React shell to bring your own brand | [`frontend-blank`](/docs/templates/blank) |
|
|
1815
|
+
|
|
1816
|
+
## Pairs well with
|
|
1817
|
+
|
|
1818
|
+
- **Any api template** — drop one in, add an `apis:` entry, and the dashboard pages become live. [`api-auth`](/docs/templates/api-auth) is the natural partner: replace the demo cookie with its real session, and `isSignedIn` becomes a real verification.
|
|
1819
|
+
|
|
1820
|
+
## Anti-patterns
|
|
1821
|
+
|
|
1822
|
+
- **Making the gated pages `static`.** A `static` page renders once at build time — the loader can't redirect per visitor, and the build rejects a `RedirectError`. Gated pages are `renderMode: 'ssr'` (or `'isr'` if the content is cacheable and not per-subject).
|
|
1823
|
+
- **Putting the gate in each page's loader.** The whole point of the layout loader is that it runs once for every page beneath it. Duplicating the session check in each page is the bug the nested layout loader exists to avoid.
|
|
1824
|
+
- **Reading only `headers` in the gate.** `headers` is populated server-side; on a client navigation it's undefined. Fall back to `document.cookie` (or your client-side session source) so the gate works on SPA navigation too — otherwise a client nav into the dashboard always bounces to `/login`.
|
|
1825
|
+
- **Trusting an unsigned cookie in production.** The demo cookie is a stand-in. A real gate verifies a *signed* session (or calls the api) — a bare `present/absent` cookie check is forgeable. See the [Sessions guide](/docs/authentication/sessions).
|
|
1826
|
+
- **Throwing a plain `Error` for redirects.** A plain `throw new Error()` hits the `error.tsx` boundary. Control-flow redirects use `RedirectError` / `redirect()`; 404s use `NotFoundError` / `notFound()`.
|
|
1827
|
+
|
|
1828
|
+
|
|
1829
|
+
|
|
1830
|
+
---
|
|
1831
|
+
|
|
1832
|
+
<!-- source: en/templates/ssr-api.md -->
|
|
1833
|
+
## SSR + api
|
|
1834
|
+
|
|
1835
|
+
_Server-rendered pages fed by a sibling api — an `ssr` loader calls `query('notes.list', {})` over the api's POST /rpc so real rows are in the first paint and the <title>, then useSubscription upgrades the same data to live. Pairs with api-backend._
|
|
1836
|
+
|
|
1837
|
+
The SSR-from-your-backend shape: a page that server-renders **real api data** into the first paint. The index page is `renderMode: 'ssr'`, and its loader runs on the server on every request, calling `query('notes.list', {})` over the api's `POST /rpc` surface — so the rows AND the document `<title>` are correct in the HTML before it's sent (crawlable, no client round-trip). The component then upgrades the same data to live with `useSubscription`. This is the piece [`frontend-ssr`](/docs/templates/ssr) leaves out — that template shows the `ssr` / `isr` mechanics but computes its data locally; this one wires the loader to a real sibling api. Template id: **`frontend-ssr-api`**.
|
|
1838
|
+
|
|
1839
|
+
## Scaffold — pairs with an api
|
|
1840
|
+
|
|
1841
|
+
This template consumes an api exposing `notes.list`. Scaffold both:
|
|
1842
|
+
|
|
1843
|
+
```bash
|
|
1844
|
+
voltro create-project acme --api=api-backend --web=frontend-ssr-api
|
|
1845
|
+
voltro add-app web --template=frontend-ssr-api --to acme
|
|
1846
|
+
```
|
|
1847
|
+
|
|
1848
|
+
## What ships
|
|
1849
|
+
|
|
1850
|
+
```text
|
|
1851
|
+
apps/acme/web/ # dir named by the app, not the template
|
|
1852
|
+
├── app.config.ts # type:web, theme:'system', apis: { app }
|
|
1853
|
+
├── package.json # + @acme/api (workspace:*)
|
|
1854
|
+
├── tsconfig.json
|
|
1855
|
+
├── README.md
|
|
1856
|
+
└── src/
|
|
1857
|
+
├── globals.css
|
|
1858
|
+
├── globals.d.ts # `declare module '*.css'`
|
|
1859
|
+
└── pages/
|
|
1860
|
+
├── layout.tsx
|
|
1861
|
+
└── index.tsx # renderMode:'ssr' — loader query() + useSubscription
|
|
1862
|
+
```
|
|
1863
|
+
|
|
1864
|
+
The only addition over a standalone web template is the api dependency: `package.json` declares `@acme/api` and `app.config.ts` names it under `apis`.
|
|
1865
|
+
|
|
1866
|
+
## Wiring the api — `apis` in `app.config.ts`
|
|
1867
|
+
|
|
1868
|
+
```ts
|
|
1869
|
+
// app.config.ts
|
|
1870
|
+
export default {
|
|
1871
|
+
type: 'web' as const,
|
|
1872
|
+
name: 'AcmeWeb',
|
|
1873
|
+
port: 5173,
|
|
1874
|
+
// The NAME ('app') is the lookup key the loader's `query` + the hooks use.
|
|
1875
|
+
// The `package` resolves the sibling api in this project; its port is
|
|
1876
|
+
// auto-discovered from its own app.config.ts, and codegen pulls its typed
|
|
1877
|
+
// rpc surface so `query` + `useSubscription` are end-to-end typed.
|
|
1878
|
+
apis: { app: { package: '@acme/api' } },
|
|
1879
|
+
}
|
|
1880
|
+
```
|
|
1881
|
+
|
|
1882
|
+
## The SSR loader — `query()` over POST /rpc
|
|
1883
|
+
|
|
1884
|
+
`query` is present ONLY server-side (`ssr` / `isr`). It invokes the api's rpc directly over `POST /rpc`, **forwarding the request's session cookie** so the same Subject + tenant resolve as the WebSocket path. A streaming query is drained to its FIRST snapshot — here, the current notes for this tenant. `meta` reads the result, so the `<title>` reflects real data in the server-rendered HTML:
|
|
1885
|
+
|
|
1886
|
+
```tsx
|
|
1887
|
+
// src/pages/index.tsx
|
|
1888
|
+
import { useLoaderData, type LoaderFn, type PageMeta } from '@voltro/web'
|
|
1889
|
+
import { useSubscription } from '@voltro/client'
|
|
1890
|
+
|
|
1891
|
+
interface Note { readonly id: string; readonly title: string; readonly body: string; readonly done: boolean }
|
|
1892
|
+
interface HomeData { readonly notes: ReadonlyArray<Note> }
|
|
1893
|
+
|
|
1894
|
+
export const renderMode = 'ssr' as const
|
|
1895
|
+
|
|
1896
|
+
export const loader: LoaderFn<HomeData> = async ({ query }) => ({
|
|
1897
|
+
// Pass the row type so the result is typed without importing the api.
|
|
1898
|
+
notes: query ? await query<ReadonlyArray<Note>>('notes.list', {}) : [],
|
|
1899
|
+
})
|
|
1900
|
+
|
|
1901
|
+
export const meta = ({ loaderData }: { loaderData: HomeData }): PageMeta => ({
|
|
1902
|
+
title: `${loaderData.notes.length} notes — Acme`,
|
|
1903
|
+
description: 'Server-rendered from the api on every request.',
|
|
1904
|
+
})
|
|
1905
|
+
```
|
|
1906
|
+
|
|
1907
|
+
`query?.(...)` is guarded because `query` is `undefined` for client-side loader invocations — only rely on it under `renderMode: 'ssr' | 'isr'`.
|
|
1908
|
+
|
|
1909
|
+
## SSR → live, one data source
|
|
1910
|
+
|
|
1911
|
+
The component renders the SSR'd snapshot, then `useSubscription` upgrades it to live once the WebSocket connects. During SSR `useSubscription` returns `undefined`, so the first paint is the loader's data; it swaps to live on the client — no flash, one data source:
|
|
1912
|
+
|
|
1913
|
+
```tsx
|
|
1914
|
+
export default function Home() {
|
|
1915
|
+
const { notes: initial } = useLoaderData<HomeData>()
|
|
1916
|
+
const { data: live } = useSubscription<ReadonlyArray<Note>>('app', 'notes.list')
|
|
1917
|
+
const notes = live ?? initial
|
|
1918
|
+
return <ul>{notes.map((n) => <li key={n.id}>{n.title}{n.done ? ' ✓' : ''}</li>)}</ul>
|
|
1919
|
+
}
|
|
1920
|
+
```
|
|
1921
|
+
|
|
1922
|
+
## Build + serve
|
|
1923
|
+
|
|
1924
|
+
```bash
|
|
1925
|
+
voltro build . # codegen pulls the api's rpc types + builds an SSR bundle
|
|
1926
|
+
voltro start . # the loader hits the api per request; the api must be running
|
|
1927
|
+
```
|
|
1928
|
+
|
|
1929
|
+
`voltro start` renders the page server-side on each request, so the api has to be up (start it alongside the web). Unlike a `static` page, there's no pre-rendered HTML — the point is fresh, per-request data.
|
|
1930
|
+
|
|
1931
|
+
## SSR vs the other render modes
|
|
1932
|
+
|
|
1933
|
+
| The page's data… | Use | Template |
|
|
1934
|
+
|---|---|---|
|
|
1935
|
+
| Must hit the api per request (per-subject, always fresh) | `renderMode: 'ssr'` | `frontend-ssr-api` (this) |
|
|
1936
|
+
| Changes occasionally + can be cached | `renderMode: 'isr'` + `revalidate` | same loader, cached HTML |
|
|
1937
|
+
| Is fully client-reactive (no SSR needed) | `useSubscription` in a default page | [`frontend-app`](/docs/templates/app) |
|
|
1938
|
+
| Is computed locally / needs no backend | `ssr` with a local loader | [`frontend-ssr`](/docs/templates/ssr) |
|
|
1939
|
+
|
|
1940
|
+
## When to use frontend-ssr-api vs. the other web templates
|
|
1941
|
+
|
|
1942
|
+
| You want… | Pick |
|
|
1943
|
+
|---|---|
|
|
1944
|
+
| Server-rendered pages whose first paint + `<title>` come from real api data | `frontend-ssr-api` |
|
|
1945
|
+
| The reactive end-to-end loop with client-side rendering | [`frontend-app`](/docs/templates/app) |
|
|
1946
|
+
| SSR / ISR mechanics without a backend | [`frontend-ssr`](/docs/templates/ssr) |
|
|
1947
|
+
| An auth-gated app shell | [`frontend-dashboard`](/docs/templates/dashboard) |
|
|
1948
|
+
|
|
1949
|
+
## Pairs well with
|
|
1950
|
+
|
|
1951
|
+
- [`api-backend`](/docs/templates/api-backend) — the documented partner; it exposes the `notes.list` query this loader calls. Any api with a streaming query works — point the loader's tag at it.
|
|
1952
|
+
|
|
1953
|
+
## Anti-patterns
|
|
1954
|
+
|
|
1955
|
+
- **Relying on `query` outside `ssr` / `isr`.** It's `undefined` for client-side loader runs. Guard with `query?.(...)`, and fetch live data in the component with `useSubscription` — not in a client-side loader.
|
|
1956
|
+
- **Using the loader result as the live source.** The loader runs once per request; for updates after first paint, subscribe in the component. The pattern is SSR'd snapshot → `useSubscription` upgrade, with `live ?? initial`.
|
|
1957
|
+
- **Making the page `static`.** A `static` page's loader runs at build time with no per-request render and no `query` — it can't read per-request api data. If the HTML must reflect current rows, it's `ssr` (or `isr` for cached-with-revalidation).
|
|
1958
|
+
- **Importing the api's VALUE exports into the page.** A type-only `import type { Note } from '@acme/api/database'` is browser-safe; importing values pulls server-only modules into the browser bundle. This template keeps a local `Note` interface to stay self-contained.
|
|
1959
|
+
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
---
|
|
1963
|
+
|
|
1964
|
+
<!-- source: en/templates/admin.md -->
|
|
1965
|
+
## Admin
|
|
1966
|
+
|
|
1967
|
+
_An auto-admin back-office that discovers your api's entities at runtime (capability map) and renders a live, reactive, permission-gated CRUD view per entity — DataTable + AutoForm + provenance + undo, assembled from shipped primitives as editable template code._
|
|
1968
|
+
|
|
1969
|
+
The back-office, done for you — without a sealed generated cage. The admin reads your api's **capability map** (`/_voltro/inspect/manifest`) at runtime and renders a live, reactive view for every entity it exposes: a `<DataTable>` over the list query and an `<AutoForm>` over the create mutation, columns and fields straight from the descriptors' schemas. Point it at **any** Voltro api and the sidebar + pages adapt — there's no per-entity code. And it's **editable template code you own**, not a runtime feature (the lesson of the schema-driven-UI graveyard). Template id: **`frontend-admin`**.
|
|
1970
|
+
|
|
1971
|
+
## Scaffold
|
|
1972
|
+
|
|
1973
|
+
```bash
|
|
1974
|
+
voltro create-project acme --api=api-backend --web=frontend-admin
|
|
1975
|
+
voltro dev # the admin discovers api-backend's entities
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
Sign in at `/login` (a demo cookie gate) → `/admin`.
|
|
1979
|
+
|
|
1980
|
+
## How discovery works
|
|
1981
|
+
|
|
1982
|
+
The sidebar and every entity page are driven by two `@voltro/client` exports:
|
|
1983
|
+
|
|
1984
|
+
```tsx
|
|
1985
|
+
import { useCapabilityManifest, deriveEntityAdmins } from '@voltro/client'
|
|
1986
|
+
|
|
1987
|
+
const { manifest } = useCapabilityManifest('app') // one-shot fetch of the capability map
|
|
1988
|
+
const entities = manifest ? deriveEntityAdmins(manifest) : []
|
|
1989
|
+
// each entity: { table, columns, listTag?, createTag?, updateTag?, deleteTag?, createScope, writeScope, deleteScope }
|
|
1990
|
+
```
|
|
1991
|
+
|
|
1992
|
+
`deriveEntityAdmins` joins each user table to the procedures that actually serve it — the query whose `source` is the table (→ the list `<DataTable>`), the mutations whose `target` is `{table, op}` (→ create/edit/delete). So the admin binds only to procedures that **exist**; it never guesses tags by naming convention.
|
|
1993
|
+
|
|
1994
|
+
## Per-entity CRUD
|
|
1995
|
+
|
|
1996
|
+
```tsx
|
|
1997
|
+
// src/pages/admin/[entity].tsx (abridged)
|
|
1998
|
+
const canCreate = useCan(spec.createScope) // <table>:create
|
|
1999
|
+
{spec.createTag && canCreate ? (
|
|
2000
|
+
<AutoForm api="app" mutation={spec.createTag} submitLabel={`Add ${spec.table}`} />
|
|
2001
|
+
) : null}
|
|
2002
|
+
|
|
2003
|
+
{spec.listTag ? (
|
|
2004
|
+
<DataTable api="app" query={spec.listTag} rowActions={rowActions} />
|
|
2005
|
+
) : null}
|
|
2006
|
+
```
|
|
2007
|
+
|
|
2008
|
+
The list is a **live subscription** — a create from the form (or anyone, in another tab) appears without a refetch. Each row's actions include a delete (gated by `useCan(spec.deleteScope)`, run via `useMutation(spec.deleteTag)`) and a **provenance** drawer (`useProvenance`) answering "why is this row here?".
|
|
2009
|
+
|
|
2010
|
+
## Permission gating
|
|
2011
|
+
|
|
2012
|
+
Write affordances are hidden via `useCan` on conventional per-entity scopes (`<table>:create`, `<table>:delete`). `<PermissionProvider>` in the admin layout feeds the current subject's scopes; the demo seeds `admin:full` (bypass) — **swap it for your session's real scopes**:
|
|
2013
|
+
|
|
2014
|
+
```tsx
|
|
2015
|
+
const { data } = useSubscription<{ scopes: string[] }>('app', 'auth.session')
|
|
2016
|
+
<PermissionProvider scopes={data?.scopes ?? []}>…</PermissionProvider>
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
This is UX gating, not enforcement — the api's own `permission()` guards remain the real authorization boundary. Row-level field visibility (vs. action-level) is a future step.
|
|
2020
|
+
|
|
2021
|
+
## Undo / redo
|
|
2022
|
+
|
|
2023
|
+
The topbar's undo bar wraps the framework's built-in undo (`useUndoLog('app')` → the `__voltro.undo.*` built-ins): revert the last change across the whole admin, server-persisted so it survives reload. It requires undo capture on (`VOLTRO_UNDO`, on by default in dev); if you run with it off, delete the `UndoBar` lines.
|
|
2024
|
+
|
|
2025
|
+
## What ships
|
|
2026
|
+
|
|
2027
|
+
```text
|
|
2028
|
+
apps/acme/web/
|
|
2029
|
+
├── app.config.ts # type:web, apis:{ app } — the api the admin introspects
|
|
2030
|
+
└── src/
|
|
2031
|
+
├── config.ts # APP_NAME
|
|
2032
|
+
├── lib/{auth,admin}.ts # demo cookie gate + demo scopes (both swap for real)
|
|
2033
|
+
└── pages/
|
|
2034
|
+
├── (marketing)/ # silent group — public landing + /login
|
|
2035
|
+
└── admin/ # /admin SSR gate (layout loader → RedirectError)
|
|
2036
|
+
├── layout.tsx # auth gate + capability-map nav + PermissionProvider + undo bar
|
|
2037
|
+
├── index.tsx # entity overview cards
|
|
2038
|
+
├── [entity].tsx # the per-entity CRUD binding
|
|
2039
|
+
└── error|loading|not-found.tsx
|
|
2040
|
+
```
|
|
2041
|
+
|
|
2042
|
+
## Make it yours
|
|
2043
|
+
|
|
2044
|
+
Everything under `src/pages/admin/` is yours. Swap the cookie gate (`lib/auth.ts`) + demo scopes (`lib/admin.ts`) for real auth; replace the generic `[entity].tsx` with a hand-built page for any entity that needs more than CRUD.
|
|
2045
|
+
|
|
2046
|
+
## When to use frontend-admin vs. the other web templates
|
|
2047
|
+
|
|
2048
|
+
| You want… | Pick |
|
|
2049
|
+
|---|---|
|
|
2050
|
+
| A back-office over your api's entities, done for you | `frontend-admin` |
|
|
2051
|
+
| An authenticated app shell (public + gated dashboard), no backend | [`frontend-dashboard`](/docs/templates/dashboard) |
|
|
2052
|
+
| The reactive end-to-end loop (one entity, hand-wired) | [`frontend-app`](/docs/templates/app) |
|
|
2053
|
+
|
|
2054
|
+
## Pairs well with
|
|
2055
|
+
|
|
2056
|
+
- **Any api template** — the admin adapts to whatever entities + procedures it exposes. [`api-data-advanced`](/docs/templates/api-data-advanced) (authors + books) shows it rendering related entities; [`api-rbac`](/docs/templates/api-rbac) supplies real scopes for the gating.
|
|
2057
|
+
|
|
2058
|
+
## Anti-patterns
|
|
2059
|
+
|
|
2060
|
+
- **Shipping the demo `admin:full` scopes to production.** That bypasses every `useCan` gate. Feed the subject's real scopes to `<PermissionProvider>`.
|
|
2061
|
+
- **Treating `useCan` as authorization.** It hides buttons; the server's `permission()` guard is the real gate. A hidden action is still callable over rpc by a crafted client.
|
|
2062
|
+
- **Assuming a naming convention.** The admin binds to discovered tags, not `<table>.create`-style guesses — so it works even when your procedures are named differently.
|