@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.
Files changed (626) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +57 -0
  3. package/README.md +26 -0
  4. package/SECURITY.md +56 -0
  5. package/THIRD-PARTY-NOTICES.md +20236 -0
  6. package/bin/voltro.mjs +43 -0
  7. package/dist/bin.d.ts +1 -0
  8. package/dist/bin.js +9 -0
  9. package/dist/commands-CfPH2Wf4.js +18061 -0
  10. package/dist/frameworkInspectState-CX2250XB.js +86 -0
  11. package/dist/index.d.ts +48 -0
  12. package/dist/index.js +5 -0
  13. package/dist/inspectState.d.ts +116 -0
  14. package/dist/inspectState.js +2 -0
  15. package/dist/startup.d.ts +26 -0
  16. package/dist/startup.js +2 -0
  17. package/dist/startupRunner-CRhuUl91.js +71 -0
  18. package/package.json +88 -0
  19. package/templates/AGENTS.core.md +258 -0
  20. package/templates/AGENTS.md +351 -0
  21. package/templates/agent-docs/_index.md +93 -0
  22. package/templates/agent-docs/_manifest.json +655 -0
  23. package/templates/agent-docs/ai.md +1845 -0
  24. package/templates/agent-docs/authentication.md +1788 -0
  25. package/templates/agent-docs/caching.md +624 -0
  26. package/templates/agent-docs/cli.md +1650 -0
  27. package/templates/agent-docs/configuration.md +295 -0
  28. package/templates/agent-docs/data.md +2172 -0
  29. package/templates/agent-docs/database/advancedqueries.md +1583 -0
  30. package/templates/agent-docs/database/columntypes.md +1200 -0
  31. package/templates/agent-docs/database/hosting.md +881 -0
  32. package/templates/agent-docs/database/migrations.md +2938 -0
  33. package/templates/agent-docs/database/misc.md +270 -0
  34. package/templates/agent-docs/database/overview.md +108 -0
  35. package/templates/agent-docs/database/querying.md +1622 -0
  36. package/templates/agent-docs/database/scaling.md +331 -0
  37. package/templates/agent-docs/database/schema.md +1458 -0
  38. package/templates/agent-docs/database/seedsdialects.md +1235 -0
  39. package/templates/agent-docs/database/transactions.md +285 -0
  40. package/templates/agent-docs/deployment.md +999 -0
  41. package/templates/agent-docs/internationalization.md +359 -0
  42. package/templates/agent-docs/introduction.md +438 -0
  43. package/templates/agent-docs/multi-tenancy.md +610 -0
  44. package/templates/agent-docs/observability.md +350 -0
  45. package/templates/agent-docs/plugins.md +1273 -0
  46. package/templates/agent-docs/reference.md +978 -0
  47. package/templates/agent-docs/routing.md +1553 -0
  48. package/templates/agent-docs/scheduling.md +666 -0
  49. package/templates/agent-docs/schema-driven-ui.md +607 -0
  50. package/templates/agent-docs/security.md +42 -0
  51. package/templates/agent-docs/templates/apibackends.md +3214 -0
  52. package/templates/agent-docs/templates/appshells.md +2062 -0
  53. package/templates/agent-docs/templates/custom.md +128 -0
  54. package/templates/agent-docs/templates/overview.md +122 -0
  55. package/templates/agent-docs/templates/serverless.md +315 -0
  56. package/templates/agent-docs/testing.md +376 -0
  57. package/templates/agent-docs/workflows.md +1351 -0
  58. package/templates/apps/api-ai/README.md +105 -0
  59. package/templates/apps/api-ai/actions/summarize.action.server.tsx +32 -0
  60. package/templates/apps/api-ai/actions/summarize.action.ts +33 -0
  61. package/templates/apps/api-ai/agents/support.agent.server.tsx +31 -0
  62. package/templates/apps/api-ai/agents/support.agent.tsx +27 -0
  63. package/templates/apps/api-ai/app.config.ts +57 -0
  64. package/templates/apps/api-ai/database/schema.ts +67 -0
  65. package/templates/apps/api-ai/package.json +27 -0
  66. package/templates/apps/api-ai/seeds/docs.seed.ts +67 -0
  67. package/templates/apps/api-ai/template.json +6 -0
  68. package/templates/apps/api-ai/tests/summarize.test.ts +50 -0
  69. package/templates/apps/api-ai/tools/searchDocs.tool.tsx +60 -0
  70. package/templates/apps/api-ai/tsconfig.json +5 -0
  71. package/templates/apps/api-auth/.env +17 -0
  72. package/templates/apps/api-auth/README.md +109 -0
  73. package/templates/apps/api-auth/actions/me.action.server.ts +20 -0
  74. package/templates/apps/api-auth/actions/me.action.ts +19 -0
  75. package/templates/apps/api-auth/app.config.ts +59 -0
  76. package/templates/apps/api-auth/database/schema.ts +26 -0
  77. package/templates/apps/api-auth/package.json +28 -0
  78. package/templates/apps/api-auth/template.json +6 -0
  79. package/templates/apps/api-auth/tests/me.test.ts +37 -0
  80. package/templates/apps/api-auth/tsconfig.json +5 -0
  81. package/templates/apps/api-backend/README.md +25 -0
  82. package/templates/apps/api-backend/app.config.ts +39 -0
  83. package/templates/apps/api-backend/database/schema.ts +57 -0
  84. package/templates/apps/api-backend/mutations/notes.create.mutation.server.ts +19 -0
  85. package/templates/apps/api-backend/mutations/notes.create.mutation.ts +37 -0
  86. package/templates/apps/api-backend/package.json +28 -0
  87. package/templates/apps/api-backend/queries/notes.query.server.ts +14 -0
  88. package/templates/apps/api-backend/queries/notes.query.ts +20 -0
  89. package/templates/apps/api-backend/template.json +6 -0
  90. package/templates/apps/api-backend/tests/notes.create.test.ts +50 -0
  91. package/templates/apps/api-backend/tsconfig.json +5 -0
  92. package/templates/apps/api-backend-deactivation/README.md +49 -0
  93. package/templates/apps/api-backend-deactivation/actions/users.get.action.server.ts +17 -0
  94. package/templates/apps/api-backend-deactivation/actions/users.get.action.ts +18 -0
  95. package/templates/apps/api-backend-deactivation/app.config.ts +20 -0
  96. package/templates/apps/api-backend-deactivation/database/schema.ts +41 -0
  97. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.server.ts +6 -0
  98. package/templates/apps/api-backend-deactivation/mutations/users.create.mutation.ts +16 -0
  99. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.server.ts +10 -0
  100. package/templates/apps/api-backend-deactivation/mutations/users.deactivate.mutation.ts +19 -0
  101. package/templates/apps/api-backend-deactivation/package.json +28 -0
  102. package/templates/apps/api-backend-deactivation/template.json +6 -0
  103. package/templates/apps/api-backend-deactivation/tests/users.deactivate.test.ts +47 -0
  104. package/templates/apps/api-backend-deactivation/tsconfig.json +5 -0
  105. package/templates/apps/api-backend-mail/README.md +39 -0
  106. package/templates/apps/api-backend-mail/actions/sendWelcome.action.server.ts +15 -0
  107. package/templates/apps/api-backend-mail/actions/sendWelcome.action.ts +21 -0
  108. package/templates/apps/api-backend-mail/app.config.ts +34 -0
  109. package/templates/apps/api-backend-mail/database/schema.ts +57 -0
  110. package/templates/apps/api-backend-mail/emails/welcome.email.tsx +106 -0
  111. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.server.ts +19 -0
  112. package/templates/apps/api-backend-mail/mutations/notes.create.mutation.ts +37 -0
  113. package/templates/apps/api-backend-mail/package.json +30 -0
  114. package/templates/apps/api-backend-mail/queries/notes.query.server.ts +14 -0
  115. package/templates/apps/api-backend-mail/queries/notes.query.ts +20 -0
  116. package/templates/apps/api-backend-mail/template.json +6 -0
  117. package/templates/apps/api-backend-mail/tests/notes.create.test.ts +50 -0
  118. package/templates/apps/api-backend-mail/tsconfig.json +5 -0
  119. package/templates/apps/api-backend-mariadb/.env.example +35 -0
  120. package/templates/apps/api-backend-mariadb/README.md +31 -0
  121. package/templates/apps/api-backend-mariadb/app.config.ts +68 -0
  122. package/templates/apps/api-backend-mariadb/database/schema.ts +57 -0
  123. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.server.ts +19 -0
  124. package/templates/apps/api-backend-mariadb/mutations/notes.create.mutation.ts +37 -0
  125. package/templates/apps/api-backend-mariadb/package.json +30 -0
  126. package/templates/apps/api-backend-mariadb/queries/notes.query.server.ts +14 -0
  127. package/templates/apps/api-backend-mariadb/queries/notes.query.ts +20 -0
  128. package/templates/apps/api-backend-mariadb/template.json +6 -0
  129. package/templates/apps/api-backend-mariadb/tests/notes.create.test.ts +50 -0
  130. package/templates/apps/api-backend-mariadb/tsconfig.json +5 -0
  131. package/templates/apps/api-backend-storage/README.md +86 -0
  132. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.server.ts +21 -0
  133. package/templates/apps/api-backend-storage/actions/uploadAvatar.action.ts +21 -0
  134. package/templates/apps/api-backend-storage/actions/uploadDocument.action.server.ts +21 -0
  135. package/templates/apps/api-backend-storage/actions/uploadDocument.action.ts +20 -0
  136. package/templates/apps/api-backend-storage/app.config.ts +38 -0
  137. package/templates/apps/api-backend-storage/database/schema.ts +57 -0
  138. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.server.ts +19 -0
  139. package/templates/apps/api-backend-storage/mutations/notes.create.mutation.ts +37 -0
  140. package/templates/apps/api-backend-storage/package.json +27 -0
  141. package/templates/apps/api-backend-storage/queries/notes.query.server.ts +14 -0
  142. package/templates/apps/api-backend-storage/queries/notes.query.ts +20 -0
  143. package/templates/apps/api-backend-storage/template.json +6 -0
  144. package/templates/apps/api-backend-storage/tests/notes.create.test.ts +50 -0
  145. package/templates/apps/api-backend-storage/tsconfig.json +5 -0
  146. package/templates/apps/api-data-advanced/.env +17 -0
  147. package/templates/apps/api-data-advanced/README.md +88 -0
  148. package/templates/apps/api-data-advanced/app.config.ts +56 -0
  149. package/templates/apps/api-data-advanced/database/actors.entity.ts +14 -0
  150. package/templates/apps/api-data-advanced/database/authors.entity.ts +30 -0
  151. package/templates/apps/api-data-advanced/database/authors.relations.ts +15 -0
  152. package/templates/apps/api-data-advanced/database/books.entity.ts +59 -0
  153. package/templates/apps/api-data-advanced/database/books.relations.ts +10 -0
  154. package/templates/apps/api-data-advanced/database/index.ts +23 -0
  155. package/templates/apps/api-data-advanced/database/tenants.entity.ts +11 -0
  156. package/templates/apps/api-data-advanced/package.json +28 -0
  157. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.server.ts +18 -0
  158. package/templates/apps/api-data-advanced/queries/authors.withBooks.query.ts +40 -0
  159. package/templates/apps/api-data-advanced/queries/books.search.query.server.ts +17 -0
  160. package/templates/apps/api-data-advanced/queries/books.search.query.ts +39 -0
  161. package/templates/apps/api-data-advanced/seeds/catalog.seed.ts +101 -0
  162. package/templates/apps/api-data-advanced/template.json +6 -0
  163. package/templates/apps/api-data-advanced/tests/queries.test.ts +124 -0
  164. package/templates/apps/api-data-advanced/tsconfig.json +5 -0
  165. package/templates/apps/api-durable/README.md +81 -0
  166. package/templates/apps/api-durable/aggregates/orderStats.aggregate.ts +49 -0
  167. package/templates/apps/api-durable/app.config.ts +41 -0
  168. package/templates/apps/api-durable/database/schema.ts +65 -0
  169. package/templates/apps/api-durable/mutations/orders.approve.mutation.server.ts +43 -0
  170. package/templates/apps/api-durable/mutations/orders.approve.mutation.ts +23 -0
  171. package/templates/apps/api-durable/mutations/orders.place.mutation.server.ts +45 -0
  172. package/templates/apps/api-durable/mutations/orders.place.mutation.ts +40 -0
  173. package/templates/apps/api-durable/package.json +28 -0
  174. package/templates/apps/api-durable/schedules/nightlyReport.cron.tsx +37 -0
  175. package/templates/apps/api-durable/startup/warm.startup.tsx +33 -0
  176. package/templates/apps/api-durable/subscribers/orderChanges.subscribe.ts +27 -0
  177. package/templates/apps/api-durable/template.json +6 -0
  178. package/templates/apps/api-durable/tests/orders.place.test.ts +91 -0
  179. package/templates/apps/api-durable/triggers/order.placed.trigger.tsx +21 -0
  180. package/templates/apps/api-durable/tsconfig.json +5 -0
  181. package/templates/apps/api-durable/workflows/order.fulfill.workflow.server.tsx +103 -0
  182. package/templates/apps/api-durable/workflows/order.fulfill.workflow.tsx +25 -0
  183. package/templates/apps/api-feature-flags/README.md +63 -0
  184. package/templates/apps/api-feature-flags/actions/notes.export.action.server.ts +11 -0
  185. package/templates/apps/api-feature-flags/actions/notes.export.action.ts +18 -0
  186. package/templates/apps/api-feature-flags/app.config.ts +41 -0
  187. package/templates/apps/api-feature-flags/database/schema.ts +37 -0
  188. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.server.ts +29 -0
  189. package/templates/apps/api-feature-flags/mutations/notes.create.mutation.ts +28 -0
  190. package/templates/apps/api-feature-flags/package.json +29 -0
  191. package/templates/apps/api-feature-flags/template.json +6 -0
  192. package/templates/apps/api-feature-flags/tests/notes.create.test.ts +67 -0
  193. package/templates/apps/api-feature-flags/tsconfig.json +5 -0
  194. package/templates/apps/api-governance/.env +4 -0
  195. package/templates/apps/api-governance/README.md +61 -0
  196. package/templates/apps/api-governance/actions/profiles.get.action.server.ts +17 -0
  197. package/templates/apps/api-governance/actions/profiles.get.action.ts +17 -0
  198. package/templates/apps/api-governance/app.config.ts +35 -0
  199. package/templates/apps/api-governance/database/schema.ts +41 -0
  200. package/templates/apps/api-governance/mutations/profiles.create.mutation.server.ts +17 -0
  201. package/templates/apps/api-governance/mutations/profiles.create.mutation.ts +21 -0
  202. package/templates/apps/api-governance/package.json +29 -0
  203. package/templates/apps/api-governance/template.json +6 -0
  204. package/templates/apps/api-governance/tests/profiles.create.test.ts +59 -0
  205. package/templates/apps/api-governance/tsconfig.json +5 -0
  206. package/templates/apps/api-kv/README.md +100 -0
  207. package/templates/apps/api-kv/actions/sync.pull.action.server.ts +75 -0
  208. package/templates/apps/api-kv/actions/sync.pull.action.ts +25 -0
  209. package/templates/apps/api-kv/actions/sync.reset.action.server.ts +25 -0
  210. package/templates/apps/api-kv/actions/sync.reset.action.ts +22 -0
  211. package/templates/apps/api-kv/actions/sync.status.action.server.ts +28 -0
  212. package/templates/apps/api-kv/actions/sync.status.action.ts +17 -0
  213. package/templates/apps/api-kv/app.config.ts +48 -0
  214. package/templates/apps/api-kv/database/schema.ts +62 -0
  215. package/templates/apps/api-kv/package.json +28 -0
  216. package/templates/apps/api-kv/queries/events.list.query.server.ts +14 -0
  217. package/templates/apps/api-kv/queries/events.list.query.ts +23 -0
  218. package/templates/apps/api-kv/template.json +6 -0
  219. package/templates/apps/api-kv/tests/sync.test.ts +103 -0
  220. package/templates/apps/api-kv/tsconfig.json +5 -0
  221. package/templates/apps/api-moderation/README.md +46 -0
  222. package/templates/apps/api-moderation/app.config.ts +33 -0
  223. package/templates/apps/api-moderation/database/schema.ts +45 -0
  224. package/templates/apps/api-moderation/mutations/comments.create.mutation.server.ts +8 -0
  225. package/templates/apps/api-moderation/mutations/comments.create.mutation.ts +20 -0
  226. package/templates/apps/api-moderation/mutations/posts.create.mutation.server.ts +9 -0
  227. package/templates/apps/api-moderation/mutations/posts.create.mutation.ts +25 -0
  228. package/templates/apps/api-moderation/package.json +29 -0
  229. package/templates/apps/api-moderation/template.json +6 -0
  230. package/templates/apps/api-moderation/tests/posts.create.test.ts +79 -0
  231. package/templates/apps/api-moderation/tsconfig.json +5 -0
  232. package/templates/apps/api-observability/README.md +106 -0
  233. package/templates/apps/api-observability/app.config.ts +32 -0
  234. package/templates/apps/api-observability/database/schema.ts +38 -0
  235. package/templates/apps/api-observability/mutations/notes.create.mutation.server.ts +33 -0
  236. package/templates/apps/api-observability/mutations/notes.create.mutation.ts +15 -0
  237. package/templates/apps/api-observability/package.json +29 -0
  238. package/templates/apps/api-observability/queries/notes.list.query.server.ts +7 -0
  239. package/templates/apps/api-observability/queries/notes.list.query.ts +14 -0
  240. package/templates/apps/api-observability/template.json +6 -0
  241. package/templates/apps/api-observability/tests/notes.create.test.ts +36 -0
  242. package/templates/apps/api-observability/tsconfig.json +5 -0
  243. package/templates/apps/api-ratelimit/README.md +49 -0
  244. package/templates/apps/api-ratelimit/app.config.ts +42 -0
  245. package/templates/apps/api-ratelimit/database/schema.ts +37 -0
  246. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.server.ts +16 -0
  247. package/templates/apps/api-ratelimit/mutations/notes.create.mutation.ts +34 -0
  248. package/templates/apps/api-ratelimit/package.json +29 -0
  249. package/templates/apps/api-ratelimit/template.json +6 -0
  250. package/templates/apps/api-ratelimit/tests/notes.create.test.ts +69 -0
  251. package/templates/apps/api-ratelimit/tsconfig.json +5 -0
  252. package/templates/apps/api-rbac/README.md +59 -0
  253. package/templates/apps/api-rbac/app.config.ts +48 -0
  254. package/templates/apps/api-rbac/database/schema.ts +37 -0
  255. package/templates/apps/api-rbac/mutations/notes.create.mutation.server.ts +22 -0
  256. package/templates/apps/api-rbac/mutations/notes.create.mutation.ts +26 -0
  257. package/templates/apps/api-rbac/package.json +29 -0
  258. package/templates/apps/api-rbac/template.json +6 -0
  259. package/templates/apps/api-rbac/tests/notes.create.test.ts +80 -0
  260. package/templates/apps/api-rbac/tsconfig.json +5 -0
  261. package/templates/apps/api-rest/README.md +85 -0
  262. package/templates/apps/api-rest/app.config.ts +70 -0
  263. package/templates/apps/api-rest/database/schema.ts +52 -0
  264. package/templates/apps/api-rest/lib/product.ts +30 -0
  265. package/templates/apps/api-rest/package.json +27 -0
  266. package/templates/apps/api-rest/routes/v1/products.create.route.tsx +45 -0
  267. package/templates/apps/api-rest/routes/v1/products.delete.route.tsx +25 -0
  268. package/templates/apps/api-rest/routes/v1/products.get.route.tsx +29 -0
  269. package/templates/apps/api-rest/routes/v1/products.list.route.tsx +43 -0
  270. package/templates/apps/api-rest/template.json +6 -0
  271. package/templates/apps/api-rest/tests/products.create.test.ts +53 -0
  272. package/templates/apps/api-rest/tsconfig.json +5 -0
  273. package/templates/apps/api-saas/README.md +106 -0
  274. package/templates/apps/api-saas/app.config.ts +51 -0
  275. package/templates/apps/api-saas/database/schema.ts +50 -0
  276. package/templates/apps/api-saas/mutations/projects.create.mutation.server.ts +52 -0
  277. package/templates/apps/api-saas/mutations/projects.create.mutation.ts +18 -0
  278. package/templates/apps/api-saas/package.json +31 -0
  279. package/templates/apps/api-saas/queries/projects.list.query.server.ts +7 -0
  280. package/templates/apps/api-saas/queries/projects.list.query.ts +18 -0
  281. package/templates/apps/api-saas/template.json +6 -0
  282. package/templates/apps/api-saas/tests/projects.create.test.ts +47 -0
  283. package/templates/apps/api-saas/tsconfig.json +5 -0
  284. package/templates/apps/api-search/README.md +68 -0
  285. package/templates/apps/api-search/app.config.ts +31 -0
  286. package/templates/apps/api-search/database/schema.ts +47 -0
  287. package/templates/apps/api-search/lib/search.ts +27 -0
  288. package/templates/apps/api-search/mutations/articles.create.mutation.server.ts +19 -0
  289. package/templates/apps/api-search/mutations/articles.create.mutation.ts +35 -0
  290. package/templates/apps/api-search/package.json +29 -0
  291. package/templates/apps/api-search/queries/articles.list.query.server.ts +14 -0
  292. package/templates/apps/api-search/queries/articles.list.query.ts +19 -0
  293. package/templates/apps/api-search/seeds/articles.seed.ts +41 -0
  294. package/templates/apps/api-search/startup/searchBackfill.startup.tsx +24 -0
  295. package/templates/apps/api-search/template.json +6 -0
  296. package/templates/apps/api-search/tests/articles.create.test.ts +69 -0
  297. package/templates/apps/api-search/tsconfig.json +5 -0
  298. package/templates/apps/api-versioning/README.md +51 -0
  299. package/templates/apps/api-versioning/actions/documents.asOf.action.server.ts +13 -0
  300. package/templates/apps/api-versioning/actions/documents.asOf.action.ts +14 -0
  301. package/templates/apps/api-versioning/actions/documents.history.action.server.ts +15 -0
  302. package/templates/apps/api-versioning/actions/documents.history.action.ts +17 -0
  303. package/templates/apps/api-versioning/app.config.ts +24 -0
  304. package/templates/apps/api-versioning/database/schema.ts +38 -0
  305. package/templates/apps/api-versioning/mutations/documents.create.mutation.server.ts +8 -0
  306. package/templates/apps/api-versioning/mutations/documents.create.mutation.ts +21 -0
  307. package/templates/apps/api-versioning/mutations/documents.update.mutation.server.ts +9 -0
  308. package/templates/apps/api-versioning/mutations/documents.update.mutation.ts +21 -0
  309. package/templates/apps/api-versioning/package.json +29 -0
  310. package/templates/apps/api-versioning/template.json +6 -0
  311. package/templates/apps/api-versioning/tests/documents.create.test.ts +37 -0
  312. package/templates/apps/api-versioning/tsconfig.json +5 -0
  313. package/templates/apps/api-webhooks/.env +6 -0
  314. package/templates/apps/api-webhooks/README.md +106 -0
  315. package/templates/apps/api-webhooks/app.config.ts +16 -0
  316. package/templates/apps/api-webhooks/database/schema.ts +49 -0
  317. package/templates/apps/api-webhooks/events/order.completed.webhook.tsx +22 -0
  318. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.server.ts +37 -0
  319. package/templates/apps/api-webhooks/mutations/orders.fulfill.mutation.ts +15 -0
  320. package/templates/apps/api-webhooks/package.json +28 -0
  321. package/templates/apps/api-webhooks/queries/orders.list.query.server.ts +7 -0
  322. package/templates/apps/api-webhooks/queries/orders.list.query.ts +15 -0
  323. package/templates/apps/api-webhooks/template.json +6 -0
  324. package/templates/apps/api-webhooks/tests/orders.fulfill.test.ts +51 -0
  325. package/templates/apps/api-webhooks/tsconfig.json +5 -0
  326. package/templates/apps/api-webhooks/webhooks/orders.webhook.tsx +35 -0
  327. package/templates/apps/changelog/README.md +77 -0
  328. package/templates/apps/changelog/app.config.ts +27 -0
  329. package/templates/apps/changelog/content/releases/0.1.0.mdx +19 -0
  330. package/templates/apps/changelog/content/releases/0.2.0.mdx +28 -0
  331. package/templates/apps/changelog/package.json +30 -0
  332. package/templates/apps/changelog/scripts/generate-rss.mjs +38 -0
  333. package/templates/apps/changelog/src/globals.css +66 -0
  334. package/templates/apps/changelog/src/globals.d.ts +16 -0
  335. package/templates/apps/changelog/src/lib/locale.test.ts +72 -0
  336. package/templates/apps/changelog/src/lib/locale.ts +55 -0
  337. package/templates/apps/changelog/src/lib/releases.ts +21 -0
  338. package/templates/apps/changelog/src/locales/de.ts +22 -0
  339. package/templates/apps/changelog/src/locales/en.ts +30 -0
  340. package/templates/apps/changelog/src/pages/[locale]/[slug].tsx +24 -0
  341. package/templates/apps/changelog/src/pages/[locale]/index.tsx +13 -0
  342. package/templates/apps/changelog/src/pages/[locale]/mirrors.test.tsx +95 -0
  343. package/templates/apps/changelog/src/pages/[slug].test.tsx +127 -0
  344. package/templates/apps/changelog/src/pages/[slug].tsx +52 -0
  345. package/templates/apps/changelog/src/pages/index.test.tsx +112 -0
  346. package/templates/apps/changelog/src/pages/index.tsx +55 -0
  347. package/templates/apps/changelog/src/pages/layout.tsx +84 -0
  348. package/templates/apps/changelog/template.json +6 -0
  349. package/templates/apps/changelog/tsconfig.json +11 -0
  350. package/templates/apps/edge-functions/README.md +55 -0
  351. package/templates/apps/edge-functions/functions/aiComplete.serverless.ts +58 -0
  352. package/templates/apps/edge-functions/functions/currencyConvert.serverless.ts +47 -0
  353. package/templates/apps/edge-functions/functions/geoGreeting.serverless.ts +44 -0
  354. package/templates/apps/edge-functions/functions/health.serverless.ts +29 -0
  355. package/templates/apps/edge-functions/functions/resolveLink.serverless.ts +32 -0
  356. package/templates/apps/edge-functions/functions/shareLink.serverless.ts +30 -0
  357. package/templates/apps/edge-functions/functions/slackNotify.serverless.ts +40 -0
  358. package/templates/apps/edge-functions/functions/verifySignature.serverless.ts +52 -0
  359. package/templates/apps/edge-functions/package.json +21 -0
  360. package/templates/apps/edge-functions/template.json +6 -0
  361. package/templates/apps/edge-functions/tsconfig.json +10 -0
  362. package/templates/apps/frontend-admin/README.md +40 -0
  363. package/templates/apps/frontend-admin/app.config.ts +37 -0
  364. package/templates/apps/frontend-admin/package.json +30 -0
  365. package/templates/apps/frontend-admin/src/config.ts +8 -0
  366. package/templates/apps/frontend-admin/src/globals.css +76 -0
  367. package/templates/apps/frontend-admin/src/globals.d.ts +6 -0
  368. package/templates/apps/frontend-admin/src/lib/admin.ts +16 -0
  369. package/templates/apps/frontend-admin/src/lib/auth.ts +24 -0
  370. package/templates/apps/frontend-admin/src/locales/de.ts +67 -0
  371. package/templates/apps/frontend-admin/src/locales/en.ts +79 -0
  372. package/templates/apps/frontend-admin/src/locales/index.ts +15 -0
  373. package/templates/apps/frontend-admin/src/pages/(marketing)/index.test.tsx +55 -0
  374. package/templates/apps/frontend-admin/src/pages/(marketing)/index.tsx +32 -0
  375. package/templates/apps/frontend-admin/src/pages/(marketing)/layout.tsx +29 -0
  376. package/templates/apps/frontend-admin/src/pages/(marketing)/login.test.tsx +73 -0
  377. package/templates/apps/frontend-admin/src/pages/(marketing)/login.tsx +35 -0
  378. package/templates/apps/frontend-admin/src/pages/admin/[entity].tsx +121 -0
  379. package/templates/apps/frontend-admin/src/pages/admin/entity.test.tsx +119 -0
  380. package/templates/apps/frontend-admin/src/pages/admin/error.tsx +20 -0
  381. package/templates/apps/frontend-admin/src/pages/admin/fallbacks.test.tsx +68 -0
  382. package/templates/apps/frontend-admin/src/pages/admin/index.test.tsx +88 -0
  383. package/templates/apps/frontend-admin/src/pages/admin/index.tsx +65 -0
  384. package/templates/apps/frontend-admin/src/pages/admin/layout.test.tsx +114 -0
  385. package/templates/apps/frontend-admin/src/pages/admin/layout.tsx +97 -0
  386. package/templates/apps/frontend-admin/src/pages/admin/loading.tsx +15 -0
  387. package/templates/apps/frontend-admin/src/pages/admin/not-found.tsx +15 -0
  388. package/templates/apps/frontend-admin/src/pages/layout.tsx +12 -0
  389. package/templates/apps/frontend-admin/src/pages/layouts.test.tsx +54 -0
  390. package/templates/apps/frontend-admin/template.json +6 -0
  391. package/templates/apps/frontend-admin/tsconfig.json +11 -0
  392. package/templates/apps/frontend-app/README.md +98 -0
  393. package/templates/apps/frontend-app/app.config.ts +43 -0
  394. package/templates/apps/frontend-app/package.json +30 -0
  395. package/templates/apps/frontend-app/src/locales/de.ts +36 -0
  396. package/templates/apps/frontend-app/src/locales/en.ts +43 -0
  397. package/templates/apps/frontend-app/src/locales/index.ts +15 -0
  398. package/templates/apps/frontend-app/src/pages/index.test.tsx +167 -0
  399. package/templates/apps/frontend-app/src/pages/index.tsx +136 -0
  400. package/templates/apps/frontend-app/src/pages/layout.tsx +41 -0
  401. package/templates/apps/frontend-app/src/pages/schema-ui.test.tsx +99 -0
  402. package/templates/apps/frontend-app/src/pages/schema-ui.tsx +74 -0
  403. package/templates/apps/frontend-app/template.json +6 -0
  404. package/templates/apps/frontend-app/tsconfig.json +11 -0
  405. package/templates/apps/frontend-blank/README.md +18 -0
  406. package/templates/apps/frontend-blank/app.config.ts +29 -0
  407. package/templates/apps/frontend-blank/package.json +29 -0
  408. package/templates/apps/frontend-blank/src/locales/de.ts +15 -0
  409. package/templates/apps/frontend-blank/src/locales/en.ts +22 -0
  410. package/templates/apps/frontend-blank/src/locales/index.ts +15 -0
  411. package/templates/apps/frontend-blank/src/pages/index.test.tsx +55 -0
  412. package/templates/apps/frontend-blank/src/pages/index.tsx +27 -0
  413. package/templates/apps/frontend-blank/src/pages/layout.test.tsx +54 -0
  414. package/templates/apps/frontend-blank/src/pages/layout.tsx +35 -0
  415. package/templates/apps/frontend-blank/template.json +6 -0
  416. package/templates/apps/frontend-blank/tsconfig.json +11 -0
  417. package/templates/apps/frontend-contact/README.md +65 -0
  418. package/templates/apps/frontend-contact/app.config.ts +25 -0
  419. package/templates/apps/frontend-contact/functions/sendMessage.serverless.ts +69 -0
  420. package/templates/apps/frontend-contact/package.json +33 -0
  421. package/templates/apps/frontend-contact/src/components/ContactForm.island.test.tsx +142 -0
  422. package/templates/apps/frontend-contact/src/components/ContactForm.island.tsx +104 -0
  423. package/templates/apps/frontend-contact/src/config.ts +12 -0
  424. package/templates/apps/frontend-contact/src/globals.css +84 -0
  425. package/templates/apps/frontend-contact/src/lib/locale.ts +55 -0
  426. package/templates/apps/frontend-contact/src/locales/de.ts +26 -0
  427. package/templates/apps/frontend-contact/src/locales/en.ts +29 -0
  428. package/templates/apps/frontend-contact/src/pages/[locale]/index.tsx +14 -0
  429. package/templates/apps/frontend-contact/src/pages/index.test.tsx +70 -0
  430. package/templates/apps/frontend-contact/src/pages/index.tsx +63 -0
  431. package/templates/apps/frontend-contact/src/pages/layout.tsx +65 -0
  432. package/templates/apps/frontend-contact/template.json +6 -0
  433. package/templates/apps/frontend-contact/tsconfig.json +11 -0
  434. package/templates/apps/frontend-dashboard/README.md +54 -0
  435. package/templates/apps/frontend-dashboard/app.config.ts +39 -0
  436. package/templates/apps/frontend-dashboard/package.json +29 -0
  437. package/templates/apps/frontend-dashboard/src/config.ts +8 -0
  438. package/templates/apps/frontend-dashboard/src/globals.css +72 -0
  439. package/templates/apps/frontend-dashboard/src/globals.d.ts +6 -0
  440. package/templates/apps/frontend-dashboard/src/lib/auth.ts +27 -0
  441. package/templates/apps/frontend-dashboard/src/locales/de.ts +49 -0
  442. package/templates/apps/frontend-dashboard/src/locales/en.ts +60 -0
  443. package/templates/apps/frontend-dashboard/src/locales/index.ts +15 -0
  444. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.test.tsx +55 -0
  445. package/templates/apps/frontend-dashboard/src/pages/(marketing)/index.tsx +34 -0
  446. package/templates/apps/frontend-dashboard/src/pages/(marketing)/layout.tsx +32 -0
  447. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.test.tsx +74 -0
  448. package/templates/apps/frontend-dashboard/src/pages/(marketing)/login.tsx +40 -0
  449. package/templates/apps/frontend-dashboard/src/pages/dashboard/error.tsx +21 -0
  450. package/templates/apps/frontend-dashboard/src/pages/dashboard/fallbacks.test.tsx +68 -0
  451. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.test.tsx +55 -0
  452. package/templates/apps/frontend-dashboard/src/pages/dashboard/index.tsx +38 -0
  453. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.test.tsx +88 -0
  454. package/templates/apps/frontend-dashboard/src/pages/dashboard/layout.tsx +69 -0
  455. package/templates/apps/frontend-dashboard/src/pages/dashboard/loading.tsx +21 -0
  456. package/templates/apps/frontend-dashboard/src/pages/dashboard/not-found.tsx +19 -0
  457. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.test.tsx +69 -0
  458. package/templates/apps/frontend-dashboard/src/pages/dashboard/settings.tsx +35 -0
  459. package/templates/apps/frontend-dashboard/src/pages/layout.tsx +12 -0
  460. package/templates/apps/frontend-dashboard/src/pages/layouts.test.tsx +54 -0
  461. package/templates/apps/frontend-dashboard/template.json +6 -0
  462. package/templates/apps/frontend-dashboard/tsconfig.json +11 -0
  463. package/templates/apps/frontend-docs/README.md +19 -0
  464. package/templates/apps/frontend-docs/app.config.ts +25 -0
  465. package/templates/apps/frontend-docs/package.json +29 -0
  466. package/templates/apps/frontend-docs/src/globals.css +32 -0
  467. package/templates/apps/frontend-docs/src/lib/locale.test.ts +72 -0
  468. package/templates/apps/frontend-docs/src/lib/locale.ts +56 -0
  469. package/templates/apps/frontend-docs/src/locales/de.ts +24 -0
  470. package/templates/apps/frontend-docs/src/locales/en.ts +28 -0
  471. package/templates/apps/frontend-docs/src/pages/[locale]/docs/[...slug].tsx +18 -0
  472. package/templates/apps/frontend-docs/src/pages/[locale]/index.tsx +13 -0
  473. package/templates/apps/frontend-docs/src/pages/[locale]/mirrors.test.tsx +67 -0
  474. package/templates/apps/frontend-docs/src/pages/docs/[...slug].test.tsx +115 -0
  475. package/templates/apps/frontend-docs/src/pages/docs/[...slug].tsx +82 -0
  476. package/templates/apps/frontend-docs/src/pages/index.test.tsx +90 -0
  477. package/templates/apps/frontend-docs/src/pages/index.tsx +45 -0
  478. package/templates/apps/frontend-docs/src/pages/layout.test.tsx +84 -0
  479. package/templates/apps/frontend-docs/src/pages/layout.tsx +66 -0
  480. package/templates/apps/frontend-docs/template.json +6 -0
  481. package/templates/apps/frontend-docs/tsconfig.json +11 -0
  482. package/templates/apps/frontend-i18n/README.md +61 -0
  483. package/templates/apps/frontend-i18n/app.config.ts +33 -0
  484. package/templates/apps/frontend-i18n/package.json +28 -0
  485. package/templates/apps/frontend-i18n/src/globals.css +46 -0
  486. package/templates/apps/frontend-i18n/src/globals.d.ts +6 -0
  487. package/templates/apps/frontend-i18n/src/lib/locale.test.ts +72 -0
  488. package/templates/apps/frontend-i18n/src/lib/locale.ts +55 -0
  489. package/templates/apps/frontend-i18n/src/locales/de.ts +24 -0
  490. package/templates/apps/frontend-i18n/src/locales/en.ts +25 -0
  491. package/templates/apps/frontend-i18n/src/pages/[locale]/about.tsx +11 -0
  492. package/templates/apps/frontend-i18n/src/pages/[locale]/index.tsx +13 -0
  493. package/templates/apps/frontend-i18n/src/pages/[locale]/mirrors.test.tsx +50 -0
  494. package/templates/apps/frontend-i18n/src/pages/about.test.tsx +75 -0
  495. package/templates/apps/frontend-i18n/src/pages/about.tsx +31 -0
  496. package/templates/apps/frontend-i18n/src/pages/index.test.tsx +102 -0
  497. package/templates/apps/frontend-i18n/src/pages/index.tsx +43 -0
  498. package/templates/apps/frontend-i18n/src/pages/layout.test.tsx +86 -0
  499. package/templates/apps/frontend-i18n/src/pages/layout.tsx +70 -0
  500. package/templates/apps/frontend-i18n/template.json +6 -0
  501. package/templates/apps/frontend-i18n/tsconfig.json +11 -0
  502. package/templates/apps/frontend-landing/README.md +17 -0
  503. package/templates/apps/frontend-landing/app.config.ts +25 -0
  504. package/templates/apps/frontend-landing/package.json +29 -0
  505. package/templates/apps/frontend-landing/src/globals.css +23 -0
  506. package/templates/apps/frontend-landing/src/lib/locale.test.ts +72 -0
  507. package/templates/apps/frontend-landing/src/lib/locale.ts +55 -0
  508. package/templates/apps/frontend-landing/src/locales/de.ts +24 -0
  509. package/templates/apps/frontend-landing/src/locales/en.ts +26 -0
  510. package/templates/apps/frontend-landing/src/pages/[locale]/index.tsx +13 -0
  511. package/templates/apps/frontend-landing/src/pages/[locale]/mirrors.test.tsx +37 -0
  512. package/templates/apps/frontend-landing/src/pages/index.test.tsx +110 -0
  513. package/templates/apps/frontend-landing/src/pages/index.tsx +75 -0
  514. package/templates/apps/frontend-landing/src/pages/layout.test.tsx +84 -0
  515. package/templates/apps/frontend-landing/src/pages/layout.tsx +66 -0
  516. package/templates/apps/frontend-landing/template.json +6 -0
  517. package/templates/apps/frontend-landing/tsconfig.json +11 -0
  518. package/templates/apps/frontend-spa/README.md +45 -0
  519. package/templates/apps/frontend-spa/app.config.ts +27 -0
  520. package/templates/apps/frontend-spa/package.json +29 -0
  521. package/templates/apps/frontend-spa/src/globals.css +84 -0
  522. package/templates/apps/frontend-spa/src/locales/de.ts +22 -0
  523. package/templates/apps/frontend-spa/src/locales/en.ts +29 -0
  524. package/templates/apps/frontend-spa/src/locales/index.ts +15 -0
  525. package/templates/apps/frontend-spa/src/pages/index.test.tsx +137 -0
  526. package/templates/apps/frontend-spa/src/pages/index.tsx +123 -0
  527. package/templates/apps/frontend-spa/src/pages/layout.tsx +27 -0
  528. package/templates/apps/frontend-spa/template.json +6 -0
  529. package/templates/apps/frontend-spa/tsconfig.json +11 -0
  530. package/templates/apps/frontend-ssr/README.md +68 -0
  531. package/templates/apps/frontend-ssr/app.config.ts +32 -0
  532. package/templates/apps/frontend-ssr/package.json +29 -0
  533. package/templates/apps/frontend-ssr/src/globals.css +67 -0
  534. package/templates/apps/frontend-ssr/src/locales/de.ts +41 -0
  535. package/templates/apps/frontend-ssr/src/locales/en.ts +54 -0
  536. package/templates/apps/frontend-ssr/src/locales/index.ts +16 -0
  537. package/templates/apps/frontend-ssr/src/pages/feed-swr.test.tsx +69 -0
  538. package/templates/apps/frontend-ssr/src/pages/feed-swr.tsx +54 -0
  539. package/templates/apps/frontend-ssr/src/pages/feed.test.tsx +73 -0
  540. package/templates/apps/frontend-ssr/src/pages/feed.tsx +64 -0
  541. package/templates/apps/frontend-ssr/src/pages/index.test.tsx +89 -0
  542. package/templates/apps/frontend-ssr/src/pages/index.tsx +72 -0
  543. package/templates/apps/frontend-ssr/src/pages/layout.tsx +37 -0
  544. package/templates/apps/frontend-ssr/template.json +6 -0
  545. package/templates/apps/frontend-ssr/tsconfig.json +11 -0
  546. package/templates/apps/frontend-ssr-api/README.md +50 -0
  547. package/templates/apps/frontend-ssr-api/app.config.ts +43 -0
  548. package/templates/apps/frontend-ssr-api/package.json +30 -0
  549. package/templates/apps/frontend-ssr-api/src/globals.css +38 -0
  550. package/templates/apps/frontend-ssr-api/src/globals.d.ts +6 -0
  551. package/templates/apps/frontend-ssr-api/src/locales/de.ts +20 -0
  552. package/templates/apps/frontend-ssr-api/src/locales/en.ts +31 -0
  553. package/templates/apps/frontend-ssr-api/src/locales/index.ts +16 -0
  554. package/templates/apps/frontend-ssr-api/src/pages/index.test.tsx +105 -0
  555. package/templates/apps/frontend-ssr-api/src/pages/index.tsx +83 -0
  556. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +28 -0
  557. package/templates/apps/frontend-ssr-api/template.json +6 -0
  558. package/templates/apps/frontend-ssr-api/tsconfig.json +11 -0
  559. package/templates/apps/frontend-static-blog/README.md +49 -0
  560. package/templates/apps/frontend-static-blog/app.config.ts +34 -0
  561. package/templates/apps/frontend-static-blog/package.json +28 -0
  562. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.test.tsx +65 -0
  563. package/templates/apps/frontend-static-blog/src/components/ReadingProgress.island.tsx +35 -0
  564. package/templates/apps/frontend-static-blog/src/content/posts.ts +64 -0
  565. package/templates/apps/frontend-static-blog/src/globals.css +75 -0
  566. package/templates/apps/frontend-static-blog/src/lib/locale.test.ts +72 -0
  567. package/templates/apps/frontend-static-blog/src/lib/locale.ts +55 -0
  568. package/templates/apps/frontend-static-blog/src/locales/de.ts +19 -0
  569. package/templates/apps/frontend-static-blog/src/locales/en.ts +26 -0
  570. package/templates/apps/frontend-static-blog/src/pages/[locale]/blog/[slug].tsx +20 -0
  571. package/templates/apps/frontend-static-blog/src/pages/[locale]/index.tsx +13 -0
  572. package/templates/apps/frontend-static-blog/src/pages/[locale]/mirrors.test.tsx +62 -0
  573. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].test.tsx +116 -0
  574. package/templates/apps/frontend-static-blog/src/pages/blog/[slug].tsx +69 -0
  575. package/templates/apps/frontend-static-blog/src/pages/index.test.tsx +100 -0
  576. package/templates/apps/frontend-static-blog/src/pages/index.tsx +58 -0
  577. package/templates/apps/frontend-static-blog/src/pages/layout.tsx +63 -0
  578. package/templates/apps/frontend-static-blog/template.json +6 -0
  579. package/templates/apps/frontend-static-blog/tsconfig.json +11 -0
  580. package/templates/baselines/bare/.env.example +37 -0
  581. package/templates/baselines/bare/README.md +47 -0
  582. package/templates/baselines/bare/baseline.json +31 -0
  583. package/templates/baselines/bare/deploy/README.md +43 -0
  584. package/templates/baselines/bare/deploy/voltro.service.example +36 -0
  585. package/templates/baselines/compose/.env.example +46 -0
  586. package/templates/baselines/compose/README.md +69 -0
  587. package/templates/baselines/compose/baseline.json +51 -0
  588. package/templates/baselines/compose/docker/.dockerignore +38 -0
  589. package/templates/baselines/compose/docker/api.Dockerfile +57 -0
  590. package/templates/baselines/compose/docker/dev.Dockerfile +35 -0
  591. package/templates/baselines/compose/docker/web.Dockerfile +59 -0
  592. package/templates/baselines/compose/docker-compose.dev.yml +89 -0
  593. package/templates/baselines/compose/docker-compose.prod.yml +87 -0
  594. package/templates/baselines/compose/docker-compose.yml +41 -0
  595. package/templates/baselines/compose-mariadb/.env.example +57 -0
  596. package/templates/baselines/compose-mariadb/README.md +78 -0
  597. package/templates/baselines/compose-mariadb/baseline.json +51 -0
  598. package/templates/baselines/compose-mariadb/docker/.dockerignore +38 -0
  599. package/templates/baselines/compose-mariadb/docker/api.Dockerfile +57 -0
  600. package/templates/baselines/compose-mariadb/docker/dev.Dockerfile +35 -0
  601. package/templates/baselines/compose-mariadb/docker/mariadb-init.sql +6 -0
  602. package/templates/baselines/compose-mariadb/docker/web.Dockerfile +59 -0
  603. package/templates/baselines/compose-mariadb/docker-compose.dev.yml +117 -0
  604. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +114 -0
  605. package/templates/baselines/compose-mariadb/docker-compose.yml +79 -0
  606. package/templates/baselines/helm/.env.example +39 -0
  607. package/templates/baselines/helm/README.md +98 -0
  608. package/templates/baselines/helm/baseline.json +53 -0
  609. package/templates/baselines/helm/charts/voltro-app/.helmignore +10 -0
  610. package/templates/baselines/helm/charts/voltro-app/Chart.yaml +10 -0
  611. package/templates/baselines/helm/charts/voltro-app/templates/_helpers.tpl +36 -0
  612. package/templates/baselines/helm/charts/voltro-app/templates/configmap.yaml +13 -0
  613. package/templates/baselines/helm/charts/voltro-app/templates/deployment-api.yaml +120 -0
  614. package/templates/baselines/helm/charts/voltro-app/templates/deployment-web.yaml +45 -0
  615. package/templates/baselines/helm/charts/voltro-app/templates/ingress.yaml +37 -0
  616. package/templates/baselines/helm/charts/voltro-app/templates/postgres-service.yaml +19 -0
  617. package/templates/baselines/helm/charts/voltro-app/templates/postgres-statefulset.yaml +73 -0
  618. package/templates/baselines/helm/charts/voltro-app/templates/secret.yaml +33 -0
  619. package/templates/baselines/helm/charts/voltro-app/templates/service-api.yaml +19 -0
  620. package/templates/baselines/helm/charts/voltro-app/templates/service-web.yaml +19 -0
  621. package/templates/baselines/helm/charts/voltro-app/values-dev.yaml +21 -0
  622. package/templates/baselines/helm/charts/voltro-app/values-prod.yaml +58 -0
  623. package/templates/baselines/helm/charts/voltro-app/values-staging.yaml +25 -0
  624. package/templates/baselines/helm/charts/voltro-app/values.yaml +109 -0
  625. package/templates/baselines/helm/deploy/README.md +94 -0
  626. 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.