feat (docs): add tutorials for supported frameworks (#3469)

### **PR Type**
Enhancement


___

### **Description**
- Add cross-framework Todos CRUD examples

- Introduce file upload/download tutorials

- Provide unified AuthProvider context implementations

- Include email templates and backend actions


___



<details> <summary><h3> File Walkthrough</h3></summary>

<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Enhancement</strong></td><td><details><summary>19
files</summary><table>
<tr>
<td><strong>Todos.tsx</strong><dd><code>Add web demo Todos CRUD
page</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-53f3b3d582fef21d5ec90cb590b73afcf09407071dba60883ed1ed7360955fc5">+648/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>todos.tsx</strong><dd><code>Add React Native tutorial
Todos</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-3f6be5ef4f443091687addd404fe71f219498b9db7dea992d18d78b4f1b6ffa3">+561/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>commonStyles.ts</strong><dd><code>Add common React Native
tutorial styles</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-bf0de16179ecc80a8e88e223c890dc2c73c30b4a9b7cadd62e910ca015ce342b">+667/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>Todos.tsx</strong><dd><code>Add React tutorial Todos
page</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-be700e4847b0a745821f156c381e583097f2083123065a45a20611c2ba1876a7">+504/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>files.tsx</strong><dd><code>Add React Native tutorial file
upload</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-d116805053a943f271a3297d06d14dba39c0f5775080e67e1e9e2778c176e9da">+454/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>Files.tsx</strong><dd><code>Add React tutorial Files
page</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-cdbca4ed68690df84463df7765dff52c85a60502f175c19519c8b42474e9282c">+404/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>signup.tsx</strong><dd><code>Enhance signup flow with email
verification</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-262b09b9dd7234cad96fe092d7131a23451c9e50b98c126c9e36599b3a127ac6">+185/-141</a></td>

</tr>

<tr>
<td><strong>FilesClient.tsx</strong><dd><code>Add Next.js Files client
component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-ea74386f232b9ae7e7957ab4eb1f0d1d6076b338173e8b1e917369fb7f1b39bb">+359/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>TodosClient.tsx</strong><dd><code>Add Next.js Todos client
component</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-96efb4db6bd61a7faee3c383e77b092eccee1a1876770c36691e7356268cfad4">+368/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>AuthProvider.tsx</strong><dd><code>Implement React
AuthProvider context</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-4cc7e41420d71d448eeec4f77043e0c5bff2c606986439454dade5ffcd433e33">+174/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>AuthProvider.tsx</strong><dd><code>Implement RN AuthProvider
context</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-93f9f27a35d0039a64cd6889a296d04d37542aa5a777925e61e8e60ee5a6d744">+148/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>actions.ts</strong><dd><code>Add Next.js server actions for
Todos</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-76d57097940d3043c8e0ab29761767861b78fe86ab8a90a2d8209f1818131d31">+223/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>signup.tsx</strong><dd><code>Add RN signup tutorial
screen</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-153c2f0bf6b3744bb84b95e356dd78c8771206a1b22218bc4c6f90641e4143ad">+183/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>explore.tsx</strong><dd><code>Add RN tutorial Explore
screen</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-980313b6c7f75f2ecd45fc476895bea122364d000f5988e21564bf5db73d7f57">+125/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>route.ts</strong><dd><code>Add Next.js file download
route</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-5c39cd02d478ad625a7cdb6df3f7b6d20a76f40488636fdd87282c66174b2bd8">+57/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>SignUp.tsx</strong><dd><code>Enhance demo signup with
verification state</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-75657efa0e1c29f59692ced3cd90e9c734836977900dc64015dd5d217bb263da">+38/-3</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>SignUpForm.tsx</strong><dd><code>Add Next.js signup client
form</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-9034cd412c47033a01e604a8250984aa1d1ecefc9884b79ebd7f7f3af17e3167">+89/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>SignInForm.tsx</strong><dd><code>Add Next.js signin client
form</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-c276aadee4507d515832909164d447c3c1a4870277d0adbd1f7e836f7c66259e">+75/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>server.tsx</strong><dd><code>Add server Nhost client for
Next.js</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-9d650defc223584e4aa06ddbf1d9a97c47a5a7ec4c9589a72ac7ea5369853400">+89/-0</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Configuration
changes</strong></td><td><details><summary>1 files</summary><table>
<tr>
<td><strong>_layout.tsx</strong><dd><code>Configure RN tutorial tab
navigation</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-c3b0bac088aef9f1a2d5cd2f4b51dd19fb301034668a062860a1e6a3512c15c9">+39/-0</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Additional
files</strong></td><td><details><summary>101 files</summary><table>
<tr>
  <td><strong>examples_tutorials_checks.yaml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-745712dc409289d30fe4caa74de567df9cc9fac97750ae5566f93a51e48bf539">+94/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>docs.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-873ce17c654718debe2fe308a2f2279bde8663686423c51f97fab2dd0722b8d9">+52/-7</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>overview.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-8c9b35da559a5de5fe14ee078573e8d487453e26ed760c03ffd7f0ad476ca24d">+5/-5</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>nextjs.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-a5210d45e7d33a57d43078dbe2a2ccbf0667b157291fd92c3986092d7d33ab9c">+0/-508</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>1-introduction.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-35413705a524a3eb2c7d096daef02d660b479fbc288674aa260e3f159988652b">+116/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>2-protected-routes.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-d901b3a6d8a96e3070f27afb934c34365aa79aeee1505238de2cb77a9ffd8546">+1364/-0</a></td>

</tr>

<tr>
  <td><strong>3-user-authentication.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-7b96576ad1b1b6b8e50dd3889e24df391662df1b7b51a111f0239f655135939f">+804/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>4-graphql-operations.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-16906637a292d22c018167d45f2da67a17fe73155ca161de91c3b329fee9d399">+958/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>5-file-uploads.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-d6153781dbf251499f2ffeeb707102349776d788859c2da0f63c8b0a8e35c821">+822/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>react.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-6f5adda9f7b29d98c68cab6ec754c0bac501666a49dd635ee830789e2c812b68">+0/-497</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>1-introduction.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-4dac916b2b27af668674965d9680f4ba8d2a417f7b155d4b052b1043fc71beb6">+116/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>2-protected-routes.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-8ab0206ae4a4c73e540cf549fdbdd9de4ed7c8d59cc88ea26a4a7ddc0a9a4460">+1435/-0</a></td>

</tr>

<tr>
  <td><strong>3-user-authentication.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-e02466ba1abd5d457724c7508da083a94b16d700cab36384567592c7d772cbf6">+647/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>4-graphql-operations.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-ae759670f7bf513c66bc08952556514e7193f23eb7cc536aebf38ad79e12c02f">+856/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>5-file-uploads.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-02dc5fa1da67b0478bb797ed99c89685be3ec4509023441fb29b730ba74fff4e">+715/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>reactnative.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-a85b3aa1caaeaa0a2cb6219ca531f89aaf2b23b41cd424c189b3cb948af1fd57">+0/-443</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>1-introduction.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-573f696fb008f0e047e97209a1595b1f5c69dd5a1fe4907b65d6883273c53096">+121/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>2-protected-routes.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-b4ab0ba037b62e9eb5ee10cf5f56fd1c148f1e053f95291e42113d9f924ae711">+1499/-0</a></td>

</tr>

<tr>
  <td><strong>3-user-authentication.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-dda89bc27be58e7fa58aef9aeb937dce455f69d9566e734a10ef4e0df993ab42">+805/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>4-graphql-operations.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-59a46fa74310dc797cdfd6e1a34d09fd8002e9accdade9d865b898419addc2bc">+881/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>5-file-uploads.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-cb48bca7fdd422250b2482167b9a136d76ab60d2d5efccb94fb1a022b814d449">+776/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>6-sign-in-with-apple.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-a4da2a574e88122505df17fcf1f9705841a8240b33f60b6fcde86e068b8b6cfe">+684/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>1-introduction.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-1eb8289a9ffc0484d2f8f21ede882c9e892d169f36b69f2ed9af940e2d1b8faa">+116/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>2-protected-routes.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-bff6efc64eeda0dae0d2a133ab6c9be9dfe725ee24b364b89060ec93344ad436">+1308/-0</a></td>

</tr>

<tr>
  <td><strong>3-user-authentication.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-f6bac1bb9476f921272f61911c703529663fb6ff37217c38d1702a1c8b70171a">+578/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>4-graphql-operations.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-3a43d1ebc0b4fd7f74699c4f33ea110bba5ffa2695e6228dccafbfa38798c633">+763/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>5-file-uploads.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-2244e838acdbe909772f82f66cc63800aa9dbf5fae292a40d2d4f663d6618dd4">+642/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>sveltekit.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-9ed4ad735d1142a65a2da2dbbd8d46c508b7aef3d032cdb102d0f329602c4ce1">+0/-497</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>vue.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-f6c4215fa6909fd3accebe0691a7364d17befb8ef90da5a4aeaee83d598c0540">+0/-504</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>1-introduction.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-9485f8a6ad6398b19aa7a3739af3eef5158a7d285dc5a55e8bc6c1d58970fb84">+116/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>2-protected-routes.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-5ba36ef4388ad18b7efc6d405e2b66a69b063d483d4521d6acb5b5da65aee97a">+1368/-0</a></td>

</tr>

<tr>
  <td><strong>3-user-authentication.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-63f56a07c48f78462b47f74420e58cb8411d46aef880cfdf24b11ddea4bb382e">+637/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>4-graphql-operations.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-ad7a84d0c924b946d992f5277cde596fb9d42eff021b888b5a8046822afabca8">+828/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>5-file-uploads.mdx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-00c26651dea4650fd77222a24b31387569067783a87d9c6715935e2bf133428e">+720/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>auth_user_security_keys.yaml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-385036f3c18b6efd97138842e95f992a0569f612f627e71091c10952d8d31609">+2/-11</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>auth_users.yaml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-aba47a0c058f0a0a8122dee4d7b4394c4a8997bac9d4ccea04c41f0d00819050">+0/-24</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>storage_files.yaml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-ea0619f75a1f7dd13ca80d81cc2e28529594a73fff0356dc0b1d49ee0d1cc9ad">+17/-36</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>tables.yaml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-a57c35142b6c4029fad7d5c8407305a61d18078cd0e264d41286625cf5cc4d30">+1/-5</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>down.sql</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-d7be59389936d96f1c3b10f2147b177d488906d2155139536fae635201859c3e">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>up.sql</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-69c6c175ba9819f7b41d6a27e78fd0390e222665c9cc95fbb7fde702f14a1f4c">+26/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>nhost.toml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-45587ca86b6441ceacd1318e72a60ee0c30c70c00edb0a06cf061e404998f3bc">+4/-4</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>actions.ts</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-a0279ec5849eb0c93ec7c2f444fbecd2b681f47d954a84da5221f72d9805c2ca">+12/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>page.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-8fffbc65277c93c49eea289ae1174cdba0632e5a295b74668437e9b1b0669097">+61/-30</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>middleware.ts</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-817666e0f29fa78d2d7f77ed93cff4efbc8d85a996ab58d0dd13ad61e47c9125">+5/-3</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>App.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-849f3aa52970f348de49a27094aac4e4b8cb8cf29580cada70d37f1a04249725">+2/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>Navigation.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-9845c755953914963f404a14104d93cd326f78e48614ece8c4d9df3ab1600ffd">+3/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>index.css</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-8e415aa656412ff29b45f56035e84691f3abcc924bfb5b4e3cd0c9ba5237543b">+200/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>+page.svelte</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-00da2a0f738ffa5c66a07437c7e85cacb7780551bad7e81899d905f065bf0892">+29/-8</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>main.css</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-1d2ba653024753e96f2db949f818dccba45498456146171c4a2f883b58177d2f">+373/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>Navigation.vue</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-27e2f662a54c47023a1342005bcf92602a846ad7a59e9d7d528aa8fbf40a0250">+7/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>index.ts</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-3bf427bc13a501168d321cf4bd428d09d8c3a042ef3be955c7a2e99bbf5f39dd">+7/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>SignUp.vue</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-1838012876d16508d53d0345615b91c7c6fbccca3c9333d2311ac5fcd7b4c210">+28/-3</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>Todos.vue</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-3334950776983589e7061d69be930b71663329a6ba56726bcbd3f1c2352dab47">+466/-0</a>&nbsp;
</td>

</tr>

<tr>
  <td><strong>Makefile</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-85a3083c78e211e9eb36d741342bcbc85a1a0c375060f45c5426b560196de27f">+2/-2</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>.secrets.example</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-be7a988f18be877344f8584befc094ec2e66fe7784a2007300053707ad8ec7f1">+16/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>Makefile</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-36623bab6fe16382fd3e61b06b9586f2b14bea7c1b492e50db14ea98935016a4">+7/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>README.md</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-ed02e93d01774c3796335c83badc90cbc8a2035040965031946b69c8d91db3dd">+29/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>env-up.sh</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-b6846a8b7ff5eea35ba5ff2aac8773f526c118ef59712a750f69578f25afda54">+8/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>package.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-f0d63d57cf77fdb7f2526d10c2d32d51ed2b6d43e3495d3f8879499e0c04f04b">+13/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>tsconfig.json</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-cad0e83f030efa9fcad494c7797838c5099909f2c2ae0de0210812b966ffa6db">+11/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>config.yaml</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-997042f08b088725275a28a7e2d275b86e2e74f3c972800e8116785d5d0fea59">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-3c921023f82fc74f5b657cb92bc8906ea19d7eb8f746713e4fae2a5a894a8a59">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-5f67fb54ebc23bfccd1e23fbb7dc955bad8ad9011fae19369c87bf3f4d3e58fa">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-3c21997bc2db49d5714afc007f214316d17eb69d8ea70dd1cfe558bdb47df4bf">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-e3ab8737d4a9ea68a115f28ce578b7502107f68212d92f035bb86178ccdfb6fe">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-ed34cbd1c3e612ac3124ccce382b84ea7cda7eb22051b07102b68f5c28c58e97">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-b8cead9967aaddc7c52be376fcd54b27c6ec223445e0063729c7255ab6c4f5fc">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-295cddc1774338f5b6ba396ef5ca9a0832b9b2346703ec22abf3b3874f903cc2">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-de3e6c264684c0dddb2f6e70dcb236fe8cb1a27789c01a11b2a707c110ce7fc2">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-ae93f5f97d52d1d42be91b50e2a44522aef884065559f993f781f1ebeaa89b66">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-13511e1d49a2796e2ee0bb2082f02ed59c49bef7f7533735e60ac62ad16cc2c6">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-293b460447946e2de5e4ba74cf64895a4f6fff7d879bb2e555c46ad7edd857a8">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-843300abe10319ac2b6ccffdd8daf3445b8eaa839486acb95566d398cad4e053">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-8ff22c43814b945c0a22ea9089755a144e54039bdd12a2f06a128b4b155b3e38">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-f9e07ca4b5b9ae54e1668c3dfb8ac32612df366eb7d44819163f17c792507295">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-4f829f73c5bce9bcbb67ed1357a111cdf9974c36d2db22493067d201b35f4cdf">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-64f6273ffcec137bbad9a104cd46e6a8776915a804bb3f89c24701071cbb2c50">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-e19bb81615465996e46da50eacac7fddf71dd8bb18200c410b1837575bd76f95">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-a202edc97b202ec41b83decb7b358a6beb89f3edef46194b576ecaada51f073d">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-c2338d16f093d53b8a86b9a859c7bef0d9ef2c83a762d46cd49ab9e2f1f2a773">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-7fff5a89330dd91d3c9781e37d8f1e1f48bfc58c686b463b7fb1f43a2d01813d">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-c9031a1f7ac5ad3d0c3c94cff2f563560c1e3c05d1acc00353e603f3dcac439e">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-847d33f3faf4a7f928445eb389ba8aa24901b9025d4bae0c38becd5e9365a881">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-3f5c827ff0c5a3270dc4c7737b0afd09327800104cb379ed2a556029eb58aef3">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-e499fcf53b46507a64be23ae309eba07e2c3ee289be4de316bd8ee83c16ea42e">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-5a6337adab234155f13e6b7d63eac5c654d5c75f4ea207c9db16b67685ce3e43">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-b8f9c01ea13429ca9990a95e81ac6bedb273ec41198f34903584e9a68c22c6ef">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-5bfe4c29aee11546871f9127b5c5a954f313c64d2344cd4e6cd402fe22cf0ded">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-262bf98a3f4801aff364e19a4c00bf37197764454b274faf1dbd947506b843a7">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-8a4ad52e5c3cd679ede51f7d411ca5aaf89f2f519f49c540e09408bf13823323">+43/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-46f8a60e052ff7b9b3d8583f83f36b826aea9c7a24af9e9998cc0b699900b3b1">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-03da104febe035cf096fccbce0dbbf32197d289de814810792a319154241200c">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-d7fff652e5e21d234b1ab733de23d179d5901ff82165ffbd9495b3a1ab1c7611">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-a84317c8ffe3b56140c923228666555d0e9e93c34eb5a8376b08ab97e6e4ae96">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-5025ef2a43d19c4f668177eea4e8002664a22522e11fef04aabbcdb2fbff3178">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-e941c399a4d0b15de5bf2b20e722c2e6ffc990ecdef15efc2367ae302a52ff63">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-126da45f26e2d9256a28fadd38f363277926de563e6b303027cbea24779c729c">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-4686c2e7f183e071c8ccd456189d9bd056ead4874078dba16b1a2b1e53c7e9e4">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>body.html</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-331385785301bc1e55cfb6a3c707e3704b32d82a38f7ccd9aa4aa834a8569df4">+52/-0</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
  <td><strong>subject.txt</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-57d1b1020e0d2e327acca4154b0e4776f3a7a5db1d6c99edae3ee45561780c6a">+1/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
  <td><strong>Additional files not shown</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3469/files#diff-2f328e4cd8dbe3ad193e49d92bcf045f47a6b72b1e9487d366f6b8288589b4ca"></a></td>

</tr>
</table></details></td></tr></tr></tbody></table>

</details>

___
This commit is contained in:
David Barroso
2025-09-18 14:57:43 +02:00
parent c6006fec30
commit 8c71dd9db9
466 changed files with 56272 additions and 2924 deletions

View File

@@ -0,0 +1,94 @@
---
name: "examples/tutorials: check and build"
on:
# pull_request_target:
pull_request:
paths:
- '.github/workflows/wf_check.yaml'
- '.github/workflows/examples_tutorials_checks.yaml'
# common build
- 'flake.nix'
- 'flake.lock'
- 'nixops/**'
- 'build/**'
# common go
- '.golangci.yaml'
- 'go.mod'
- 'go.sum'
- 'vendor/**'
# codegen
- 'tools/codegen/**'
# common javascript
- ".npmrc"
- ".prettierignore"
- ".prettierrc.js"
- "audit-ci.jsonc"
- "package.json"
- "pnpm-workspace.yaml"
- "pnpm-lock.yaml"
- "turbo.json"
# nhpst-js
- 'packages/nhost-js/**'
# tutorials
- 'examples/tutorials/**'
push:
branches:
- main
jobs:
check-permissions:
runs-on: ubuntu-latest
steps:
- run: |
echo "github.event_name: ${{ github.event_name }}"
echo "github.event.pull_request.author_association: ${{ github.event.pull_request.author_association }}"
- name: "This task will run and fail if user has no permissions and label safe_to_test isn't present"
if: "github.event_name == 'pull_request_target' && ! ( contains(github.event.pull_request.labels.*.name, 'safe_to_test') || contains(fromJson('[\"OWNER\", \"MEMBER\", \"COLLABORATOR\"]'), github.event.pull_request.author_association) )"
run: |
exit 1
tests:
uses: ./.github/workflows/wf_check.yaml
needs:
- check-permissions
with:
NAME: tutorials
PATH: examples/tutorials
GIT_REF: ${{ github.sha }}
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
build_artifacts:
uses: ./.github/workflows/wf_build_artifacts.yaml
needs:
- check-permissions
with:
NAME: tutorials
PATH: examples/tutorials
GIT_REF: ${{ github.sha }}
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
DOCKER: false
secrets:
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
remove_label:
runs-on: ubuntu-latest
needs:
- check-permissions
steps:
- uses: actions-ecosystem/action-remove-labels@v1
with:
labels: |
safe_to_test
if: contains(github.event.pull_request.labels.*.name, 'safe_to_test')

View File

@@ -41,14 +41,59 @@
]
},
{
"group": "Tutorials",
"icon": "book",
"group": "Tutorial: ToDo App (React)",
"icon": "react",
"pages": [
"/getting-started/tutorials/react",
"/getting-started/tutorials/nextjs",
"/getting-started/tutorials/vue",
"/getting-started/tutorials/sveltekit",
"/getting-started/tutorials/reactnative"
"/getting-started/tutorials/react/1-introduction",
"/getting-started/tutorials/react/2-protected-routes",
"/getting-started/tutorials/react/3-user-authentication",
"/getting-started/tutorials/react/4-graphql-operations",
"/getting-started/tutorials/react/5-file-uploads"
]
},
{
"group": "Tutorial: ToDo App (Next.js)",
"icon": "triangle",
"pages": [
"/getting-started/tutorials/nextjs/1-introduction",
"/getting-started/tutorials/nextjs/2-protected-routes",
"/getting-started/tutorials/nextjs/3-user-authentication",
"/getting-started/tutorials/nextjs/4-graphql-operations",
"/getting-started/tutorials/nextjs/5-file-uploads"
]
},
{
"group": "Tutorial: ToDo App (Vue)",
"icon": "vuejs",
"pages": [
"/getting-started/tutorials/vue/1-introduction",
"/getting-started/tutorials/vue/2-protected-routes",
"/getting-started/tutorials/vue/3-user-authentication",
"/getting-started/tutorials/vue/4-graphql-operations",
"/getting-started/tutorials/vue/5-file-uploads"
]
},
{
"group": "Tutorial: ToDo App (Svelte)",
"icon": "s",
"pages": [
"/getting-started/tutorials/svelte/1-introduction",
"/getting-started/tutorials/svelte/2-protected-routes",
"/getting-started/tutorials/svelte/3-user-authentication",
"/getting-started/tutorials/svelte/4-graphql-operations",
"/getting-started/tutorials/svelte/5-file-uploads"
]
},
{
"group": "Tutorial: ToDo App (React Native)",
"icon": "mobile-notch",
"pages": [
"/getting-started/tutorials/reactnative/1-introduction",
"/getting-started/tutorials/reactnative/2-protected-routes",
"/getting-started/tutorials/reactnative/3-user-authentication",
"/getting-started/tutorials/reactnative/4-graphql-operations",
"/getting-started/tutorials/reactnative/5-file-uploads",
"/getting-started/tutorials/reactnative/6-sign-in-with-apple"
]
}
]

View File

@@ -65,35 +65,35 @@ Follow one of your tutorials where we walk you through building a Todo Manager a
<Card
title="Next.js"
icon="react"
href="/getting-started/tutorials/nextjs"
href="/getting-started/tutorials/nextjs/1-introduction"
>
Todo Manager with Nhost and NextJS
</Card>
<Card
title="React"
icon="react"
href="/getting-started/tutorials/react"
href="/getting-started/tutorials/react/1-introduction"
>
Todo Manager with Nhost and React
</Card>
<Card
title="Vue"
icon="vuejs"
href="/getting-started/tutorials/vue"
href="/getting-started/tutorials/vue/1-introduction"
>
Todo Manager with Nhost and Vue
</Card>
<Card
title="Svelte"
icon="S"
href="/getting-started/tutorials/sveltekit"
href="/getting-started/tutorials/svelte/1-introduction"
>
Todo Manager with Nhost and SvelteKit
</Card>
<Card
title="React Native"
icon="mobile-notch"
href="/getting-started/tutorials/reactnative"
href="/getting-started/tutorials/reactnative/1-introduction"
>
Todo Manager with Nhost and React Native
</Card>

View File

@@ -1,508 +0,0 @@
---
title: Build a Todo Manager with Next.js
description: Learn how to use Nhost with Next.js
sidebarTitle: Next.js
icon: react
---
In this tutorial, you will build a simple **Todo Manager** application with Nhost and Next.js. Along the way you will interact with the Database, Authentication, and Storage services.
The Todo Manager will allow users to see public `todos` and sign in using a Magic Link to manage their own `todos` with attachments.
<CardGroup cols={3}>
<Card title="Database">
To store todos
</Card>
<Card title="Auth">
To sign in users
</Card>
<Card title="Storage">
To store attachments
</Card>
</CardGroup>
## Setup Nhost Backend
In this section, you will create and setup your first Nhost project.
### Create project
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
- Dedicated PostgreSQL
- Realtime APIs over your data
- Authentication for managing your users
- Storage for handling files
### Create table `todos`
On the project's dashboard, navigate to **Database** and create a new table called `todos`.
![Database](/images/tutorials/todos-react-database.png)
You can either copy and paste the following SQL into the SQL Editor, **Database -> SQL Editor**, or manually create the table by clicking on **New Table**.
<Tabs>
<Tab title="SQL Editor">
Copy and paste the following SQL into the SQL Editor and press **Run**.
<Note>Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs</Note>
```sql SQL
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
completed bool DEFAULT 'false' NOT NULL,
file_id uuid,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (file_id) REFERENCES storage.files (id) ON UPDATE SET NULL ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE SET NULL ON DELETE SET NULL
);
```
</Tab>
<Tab title="UI">
Click on **New Table** and fill in the details for the `todos` table as shown.
![New Table](/images/tutorials/todos-react-database-new-table.png)
</Tab>
</Tabs>
You should now see a new table called `todos` on the left panel, below **New Table**.
### Set permissions for todos
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **...**, followed by **Edit Permissions**.
You will set permissions for the `user` role and actions `insert`, `select`, `update`, and `delete`.
<Tabs>
<Tab title="insert">
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
![User Insert](/images/tutorials/todos-react-permissions-insert.png)
</Tab>
<Tab title="select">
Click on the right cell for the `user` role and action `select` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-select.png)
</Tab>
<Tab title="update">
Click on the right cell for the `user` role and action `update` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-update.png)
</Tab>
<Tab title="delete">
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
![User Delete](/images/tutorials/todos-react-permissions-delete.png)
</Tab>
</Tabs>
### Set permissions for files
The `files` table is managed by Nhost and is defined on the `storage` schema. Click on the dropdown right next to `schema.public` and choose `schema.storage`.
With the `files` table selected, click on **...**, followed by **Edit Permissions**.
As before, we want to set permissions for the `user` role and actions `insert`, `select`, `delete`.
<Tabs>
<Tab title="insert">
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
![User Insert](/images/tutorials/todos-react-permissions-files-insert.png)
</Tab>
<Tab title="select">
Click on the right cell for the `user` role and action `select` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-files-select.png)
</Tab>
<Tab title="delete">
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
![User Delete](/images/tutorials/todos-react-permissions-files-delete.png)
</Tab>
</Tabs>
### Enable Sign In with Magic Link
To enable Magic Links, navigate to your project's **Settings -> Sign-In Methods**, toggle Magic Link, and save.
### Recap
<Steps>
<Step title="Nhost project created">
</Step>
<Step title="Database todos created">
</Step>
<Step title="Permissions set for todos and files">
</Step>
<Step title="Magic Link enabled">
</Step>
</Steps>
## Setup Next.js Application
Now that we have Nhost configured, let's move on to setup the React application and the Nhost client.
### Create React Application
Run the following command in your terminal to create a React application using Vite.
```bash Terminal
npx create-next-app@next-14 --no-eslint \
--src-dir \
--no-tailwind \
--import-alias "@/*" \
--js \
--app \
nhost-nextjs
```
### Install Nhost React package
To install Nhost's React package, run the following command.
```bash Terminal
cd nhost-nextjs && npm install @nhost/nextjs
```
#### Configure the Nhost Client
Create a new file with the following code to create a Nhost client. Replace `<SUBDOMAIN>` and `<REGION>` with the values from the project created earlier.
```ts ./src/lib/nhost.ts
import { NhostClient } from "@nhost/nextjs";
export const nhost = new NhostClient({
subdomain: "<SUBDOMAIN>",
region: "<REGION>"
});
```
<Info>The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project Info**</Info>
### Setup Sign In Component
It is time to setup a new React component to handle the login functionality. Users will be able to sign in using a Magic Link.
Create a new file with the following content:
```js ./src/app/signin.js
"use client";
import { useState } from 'react'
import { useSignInEmailPasswordless } from '@nhost/nextjs'
export default function SignIn() {
const [loading, setLoading] = useState(false)
const [email, setEmail] = useState('')
const { signInEmailPasswordless, error } = useSignInEmailPasswordless()
const handleSignIn = async (event) => {
event.preventDefault()
setLoading(true)
const { error } = await signInEmailPasswordless(email)
if (error) {
console.error({ error })
return
}
setLoading(false)
alert('Magic Link Sent!')
}
return (
<div>
<h1>Todo Manager</h1>
<p>powered by Nhost and React</p>
<form onSubmit={handleSignIn}>
<div>
<input
type="email"
placeholder="Your email"
value={email}
required={true}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<button disabled={loading}>
{loading ? <span>Loading</span> : <span>Send me a Magic Link!</span>}
</button>
</div>
{error && <p>{error.message}</p>}
</form>
</div>
)
}
```
### Setup `Todos` Component
Now that users can sign in, let's move on and create the authenticated page that lists a user's todos and has a form for managing todos with attachments.
```js ./src/app/todos.jsx
"use client";
import { useState, useEffect } from 'react'
import { useNhostClient, useFileUpload } from '@nhost/nextjs'
const deleteTodo = `
mutation($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`
const createTodo = `
mutation($title: String!, $file_id: uuid) {
insert_todos_one(object: {title: $title, file_id: $file_id}) {
id
}
}
`
const getTodos = `
query {
todos {
id
title
file_id
completed
}
}
`
export default function Todos() {
const [loading, setLoading] = useState(true)
const [todos, setTodos] = useState([])
const [todoTitle, setTodoTitle] = useState('')
const [todoAttachment, setTodoAttachment] = useState(null)
const [fetchAll, setFetchAll] = useState(false)
const nhostClient = useNhostClient()
const { upload } = useFileUpload()
useEffect(() => {
async function fetchTodos() {
setLoading(true)
const { data, error } = await nhostClient.graphql.request(getTodos)
if (error) {
console.error({ error })
return
}
setTodos(data.todos)
setLoading(false)
}
fetchTodos()
return () => {
setFetchAll(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchAll])
const handleCreateTodo = async (e) => {
e.preventDefault()
let todo = { title: todoTitle }
if (todoAttachment) {
const { id, error } = await upload({
file: todoAttachment,
name: todoAttachment.name
})
if (error) {
console.error({ error })
return
}
todo.file_id = id
}
const { error } = await nhostClient.graphql.request(createTodo, todo)
if (error) {
console.error({ error })
}
setTodoTitle('')
setTodoAttachment(null)
setFetchAll(true)
}
const handleDeleteTodo = async (id) => {
if (!window.confirm('Are you sure you want to delete this TODO?')) {
return
}
const todo = todos.find((todo) => todo.id === id)
if (todo.file_id) {
await nhostClient.storage.delete({ fileId: todo.file_id })
}
const { error } = await nhostClient.graphql.request(deleteTodo, { id })
if (error) {
console.error({ error })
}
setFetchAll(true)
}
const completeTodo = async (id) => {
const { error } = await nhostClient.graphql.request(
`
mutation($id: uuid!) {
update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
completed
}
}
`,
{ id }
)
if (error) {
console.error({ error })
}
setFetchAll(true)
}
const openAttachment = async (todo) => {
const { presignedUrl, error } = await nhostClient.storage.getPresignedUrl({
fileId: todo.file_id
})
if (error) {
console.error({ error })
return
}
window.open(presignedUrl.url, '_blank')
}
return (
<>
<div className="container">
<div className="form-section">
<h2>Add a new TODO</h2>
<form onSubmit={handleCreateTodo}>
<div className="input-group">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
placeholder="Title"
value={todoTitle}
onChange={(e) => setTodoTitle(e.target.value)}
/>
</div>
<div className="input-group">
<label htmlFor="file">File (optional)</label>
<input id="file" type="file" onChange={(e) => setTodoAttachment(e.target.files[0])} />
</div>
<div className="submit-group">
<button type="submit" disabled={!todoTitle}>
Add Todo
</button>
</div>
</form>
</div>
<div className="todos-section">
{(!loading &&
todos.map((todo) => (
<div className="todo-item" key={todo.id ?? 0}>
<input
type="checkbox"
checked={todo.completed}
disabled={todo.completed}
id={`todo-${todo.id}`}
onChange={() => completeTodo(todo.id)}
/>
{todo.file_id && (
<span>
<a onClick={() => openAttachment(todo)}> Open Attachment</a>
</span>
)}
<label htmlFor={`todo-${todo.id}`} className="todo-title">
{todo.completed && <s>{todo.title}</s>}
{!todo.completed && todo.title}
</label>
<button type="button" onClick={() => handleDeleteTodo(todo.id)}>
Delete
</button>
</div>
))) || (
<div className="todo-item">
<label className="todo-title">Loading...</label>
</div>
)}
</div>
</div>
<div className="sign-out-section">
<button type="button" onClick={() => nhostClient.auth.signOut()}>
Sign Out
</button>
</div>
</>
)
}
```
With both `SignIn` and `Todos` in place, update `./src/App.jsx` to use the new components:
```js ./src/app/App.js
"use client";
import './App.css'
import { NhostProvider } from '@nhost/nextjs'
import { nhost } from '../lib/nhost.js'
import SignIn from './signin'
import Todos from './todos'
import { useEffect, useState } from 'react'
function App() {
const [session, setSession] = useState(null)
useEffect(() => {
setSession(nhost.auth.getSession())
nhost.auth.onAuthStateChanged((_, session) => {
setSession(session)
})
}, [])
return (
<NhostProvider nhost={nhost}>
{session ? <Todos session={session} /> : <SignIn />}
</NhostProvider>
)
}
export default App
```
## The End
Run the Todo Manager with:
```bash Terminal
npm run dev -- --port 3000
```
Open your browser on [localhost:3000](localhost:3000) to see your new application in action.

View File

@@ -0,0 +1,116 @@
---
title: Create Your Nhost Project
description: Learn how to create and set up a new Nhost project to get started building your Next.js application
sidebarTitle: Create Project
icon: plus
---
Welcome to the **Full-Stack Next.js Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete React application with Nhost that demonstrates authentication, database operations, and file management.
## About This Tutorial Series
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern web applications with Nhost and Next.js. By the end of the series, you'll have built a fully functional application featuring:
- **User Authentication** - Complete sign up, sign in, and email verification flow
- **Todo Management** - Users can create, update, delete, and mark todos as complete
- **File Uploads** - Users can upload and manage files with proper permissions
- **Protected Routes** - Secure areas that only authenticated users can access
<Info>
This is **Part 1** in the Full-Stack Next.js Development with Nhost series. This tutorial sets up the foundation by creating your Nhost project and understanding the series structure.
</Info>
## Full-Stack Next.js Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/nextjs/1-introduction">
**Current** - Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/nextjs/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/nextjs/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/nextjs/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/nextjs/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## What You'll Learn
Throughout this series, you'll master:
- Setting up and configuring Nhost projects
- Implementing secure authentication flows
- Building protected routes with Next.js Router
- Performing GraphQL queries and mutations
- Managing file uploads and storage
- Configuring database permissions and security
- Building responsive Next.js interfaces
## Prerequisites
- Node.js 20+ installed on your machine
- Basic knowledge of Next.js and JavaScript
- Understanding of modern web development concepts
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
## Step-by-Step Guide
<Steps>
<Step>
### Sign Up or Log in
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
![sign up/sign in](/images/tutorials/create-nhost-project/1.png)
</Step>
<Step>
### Create a New Project
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
![2](/images/tutorials/create-nhost-project/2.png)
</Step>
<Step>
### Take note of your project subdomain and region
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
![3](/images/tutorials/create-nhost-project/3.png)
</Step>
</Steps>
## What's Next?
With your Nhost project created, you now have access to:
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
- [**File Storage**](/products/storage/overview) - For uploading and managing files
- [**Functions**](/products/functions/overview) - For running serverless functions
In the [next tutorial](/getting-started/tutorials/nextjs/2-protected-routes), you'll start building your Next.js application and learn how to protect routes based on user authentication status.
<Tip>
Keep your project subdomain and region handy - you'll need them throughout the series to connect your Next.js application to the Nhost backend.
</Tip>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,804 @@
---
title: User Authentication in Next.js
description: Learn how to implement user authentication in a Next.js application using Nhost
sidebarTitle: "User Authentication"
icon: user
---
This tutorial part builds upon the [Protected Routes part](/getting-started/tutorials/nextjs/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
<Info>
This is **Part 3** in the Full-Stack Next.js Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling using Next.js App Router.
</Info>
## Full-Stack Next.js Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/nextjs/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/nextjs/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/nextjs/3-user-authentication">
**Current** - Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/nextjs/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/nextjs/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [Protected Routes part](/getting-started/tutorials/nextjs/2-protected-routes) first
- The project from the previous part set up and running
## Step-by-Step Guide
<Steps>
<Step>
### Create the Sign In Flow
In this step, we'll create a complete sign-in flow using Next.js App Router patterns. We'll build three key files: a server component for the main page that handles URL parameters, a client component for the interactive form, and server actions for secure authentication processing.
<Tabs>
<Tab title="Page Component">
The main sign-in page is a **server component** that handles URL parameters (like error messages) and renders the sign-in form. This component runs on the server and can access search parameters directly.
```tsx src/app/signin/page.tsx
import Link from "next/link";
import SignInForm from "./SignInForm";
export default async function SignIn({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
// Extract error from URL parameters
const params = await searchParams;
const error = params?.error;
return (
<div>
<h1>Sign In</h1>
<SignInForm initialError={error} />
<div className="auth-links">
<p>
Don't have an account? <Link href="/signup">Sign Up</Link>
</p>
</div>
</div>
);
}
```
</Tab>
<Tab title="Form Component">
The sign-in form is a **client component** that handles user interactions, loading states, and form submissions. It communicates with server actions and provides real-time feedback to users.
```tsx src/app/signin/SignInForm.tsx
"use client";
import { useRouter } from "next/navigation";
import { useId, useState } from "react";
import { signIn } from "./actions";
interface SignInFormProps {
initialError?: string;
}
export default function SignInForm({ initialError }: SignInFormProps) {
const [error, setError] = useState<string | undefined>(initialError);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const emailId = useId();
const passwordId = useId();
const handleSubmit = async (formData: FormData) => {
setIsLoading(true);
setError(undefined);
try {
const result = await signIn(formData);
if (result.redirect) {
router.push(result.redirect);
} else if (result.error) {
setError(result.error);
}
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "An error occurred during sign in",
);
} finally {
setIsLoading(false);
}
};
return (
<form action={handleSubmit} className="auth-form">
<div className="auth-form-field">
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
name="email"
type="email"
required
className="auth-input"
/>
</div>
<div className="auth-form-field">
<label htmlFor={passwordId}>Password</label>
<input
id={passwordId}
name="password"
type="password"
required
className="auth-input"
/>
</div>
{error && <div className="auth-error">{error}</div>}
<button
type="submit"
disabled={isLoading}
className="auth-button secondary"
>
{isLoading ? "Signing In..." : "Sign In"}
</button>
</form>
);
}
```
</Tab>
<Tab title="Server Actions">
Server actions handle the authentication logic securely on the server side. They validate form data, communicate with Nhost, and return appropriate responses for success or error states.
```tsx src/app/signin/actions.ts
"use server";
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import { createNhostClient } from "../../lib/nhost/server";
export async function signIn(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
if (!email || !password) {
return {
error: "Email and password are required",
};
}
try {
const nhost = await createNhostClient();
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
if (response.body?.session) {
return { redirect: "/profile" };
} else {
return {
error: "Failed to sign in. Please check your credentials.",
};
}
} catch (err) {
const error = err as FetchError<ErrorResponse>;
return {
error: `An error occurred during sign in: ${error.message}`,
};
}
}
```
</Tab>
</Tabs>
</Step>
<Step>
### Create the Sign Up Flow
In this step, we'll build the user registration system with email verification support. The sign-up flow includes handling both the registration form and the email verification success state, all using Next.js server and client components.
<Tabs>
<Tab title="Page Component">
The sign-up page is a **server component** that manages different states: showing the registration form or displaying the email verification success message. It handles URL parameters to determine which state to render.
```tsx src/app/signup/page.tsx
import Link from "next/link";
import SignUpForm from "./SignUpForm";
export default async function SignUp({
searchParams,
}: {
searchParams: Promise<{
error?: string;
verify?: string;
email?: string;
}>;
}) {
// Extract parameters from URL
const params = await searchParams;
const error = params?.error;
const verificationSent = params?.verify === "success";
const email = params?.email;
if (verificationSent) {
return (
<div>
<h1>Check Your Email</h1>
<div className="success-message">
<p>
We've sent a verification link to <strong>{email}</strong>
</p>
<p>
Please check your email and click the verification link to activate
your account.
</p>
</div>
<p>
<Link href="/signin">Back to Sign In</Link>
</p>
</div>
);
}
return (
<div>
<h1>Sign Up</h1>
<SignUpForm initialError={error} />
<div className="auth-links">
<p>
Already have an account? <Link href="/signin">Sign In</Link>
</p>
</div>
</div>
);
}
```
</Tab>
<Tab title="Form Component">
The registration form is a **client component** that collects user information (display name, email, password) and handles form validation, loading states, and error feedback during the sign-up process.
```tsx src/app/signup/SignUpForm.tsx
"use client";
import { useRouter } from "next/navigation";
import { useId, useState } from "react";
import { signUp } from "./actions";
interface SignUpFormProps {
initialError?: string;
}
export default function SignUpForm({ initialError }: SignUpFormProps) {
const [error, setError] = useState<string | undefined>(initialError);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const displayNameId = useId();
const emailId = useId();
const passwordId = useId();
const handleSubmit = async (formData: FormData) => {
setIsLoading(true);
setError(undefined);
try {
const result = await signUp(formData);
if (result.redirect) {
router.push(result.redirect);
} else if (result.error) {
setError(result.error);
}
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "An error occurred during sign up",
);
} finally {
setIsLoading(false);
}
};
return (
<form action={handleSubmit} className="auth-form">
<div className="auth-form-field">
<label htmlFor={displayNameId}>Display Name</label>
<input
id={displayNameId}
name="displayName"
type="text"
required
className="auth-input"
/>
</div>
<div className="auth-form-field">
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
name="email"
type="email"
required
className="auth-input"
/>
</div>
<div className="auth-form-field">
<label htmlFor={passwordId}>Password</label>
<input
id={passwordId}
name="password"
type="password"
required
minLength={8}
className="auth-input"
/>
<small className="help-text">Minimum 8 characters</small>
</div>
{error && <div className="auth-error">{error}</div>}
<button
type="submit"
disabled={isLoading}
className="auth-button primary"
>
{isLoading ? "Creating Account..." : "Sign Up"}
</button>
</form>
);
}
```
</Tab>
<Tab title="Server Actions">
Server actions handle user registration with Nhost, including setting up email verification. They process form data, create user accounts, and coordinate the email verification flow by setting the appropriate redirect URLs.
```tsx src/app/signup/actions.ts
"use server";
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import { createNhostClient } from "../../lib/nhost/server";
export async function signUp(formData: FormData) {
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const displayName = formData.get("displayName") as string;
if (!email || !password || !displayName) {
return {
error: "All fields are required",
};
}
try {
const nhost = await createNhostClient();
const response = await nhost.auth.signUpEmailPassword({
email,
password,
options: {
displayName,
// Set the redirect URL for email verification
redirectTo: `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/verify`,
},
});
if (response.body?.session) {
// Successfully signed up and automatically signed in
return { redirect: "/profile" };
} else {
// Verification email sent
return {
redirect: `/signup?verify=success&email=${encodeURIComponent(email)}`,
};
}
} catch (err) {
const error = err as FetchError<ErrorResponse>;
return {
error: `An error occurred during sign up: ${error.message}`,
};
}
}
```
</Tab>
</Tabs>
</Step>
<Step>
### Create the Email Verification System
In this step, we'll implement email verification using Next.js Route Handlers. When users click the verification link in their email, it will process the token server-side and either redirect them to their profile or show an error page with debugging information.
<Tabs>
<Tab title="Route Handler">
The verification Route Handler is a **server-side API endpoint** that processes email verification tokens. It validates the token, handles edge cases (like already signed-in users), and redirects appropriately based on the verification result.
```tsx src/app/verify/route.ts
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { createNhostClient } from "../../lib/nhost/server";
export async function GET(request: NextRequest) {
const refreshToken = request.nextUrl.searchParams.get("refreshToken");
if (!refreshToken) {
// Collect all query parameters for debugging
const params = new URLSearchParams(request.nextUrl.searchParams);
params.set("message", "No refresh token provided");
return NextResponse.redirect(
new URL(`/verify/error?${params.toString()}`, request.url),
);
}
try {
const nhost = await createNhostClient();
if (nhost.getUserSession()) {
// Collect all query parameters
const params = new URLSearchParams(request.nextUrl.searchParams);
params.set("message", "Already signed in");
return NextResponse.redirect(
new URL(`/verify/error?${params.toString()}`, request.url),
);
}
// Process the verification token
await nhost.auth.refreshToken({ refreshToken });
// Redirect to profile on successful verification
return NextResponse.redirect(new URL("/profile", request.url));
} catch (err) {
const error = err as FetchError<ErrorResponse>;
const errorMessage = `Failed to verify token: ${error.message}`;
// Collect all query parameters
const params = new URLSearchParams(request.nextUrl.searchParams);
params.set("message", errorMessage);
return NextResponse.redirect(
new URL(`/verify/error?${params.toString()}`, request.url),
);
}
}
```
</Tab>
<Tab title="Error Page">
The verification error page is a **server component** that displays helpful error messages and debugging information when email verification fails. It shows the specific error message and any URL parameters that might help diagnose the issue.
```tsx src/app/verify/error/page.tsx
import Link from "next/link";
export default async function VerifyError({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const params = await searchParams;
const message = params?.message || "Unknown verification error";
// Filter out the message to show other URL parameters
const urlParams = Object.entries(params).filter(([key]) => key !== "message");
return (
<div>
<h1>Email Verification</h1>
<div className="page-center">
<p className="verification-status error">Verification failed</p>
<p className="margin-bottom">{message}</p>
{urlParams.length > 0 && (
<div className="debug-panel">
<p className="debug-title">URL Parameters:</p>
{urlParams.map(([key, value]) => (
<div key={key} className="debug-item">
<span className="debug-key">{key}:</span>{" "}
<span className="debug-value">{value}</span>
</div>
))}
</div>
)}
<Link href="/signin" className="auth-button secondary">
Back to Sign In
</Link>
</div>
</div>
);
}
```
</Tab>
</Tabs>
<Warning>
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
1. Go to your Nhost project dashboard
2. Navigate to **Settings → Authentication**
3. Add your local development URL (e.g., `http://localhost:3000`) to the **Allowed Redirect URLs** field
4. Ensure your production domain is also added when deploying
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
</Warning>
</Step>
<Step>
### Create the Sign Out System
In this step, we'll implement user sign-out functionality using Next.js patterns. We'll create a client component for the sign-out button and a server action to handle the actual sign-out process securely on the server side.
<Tabs>
<Tab title="Sign Out Button">
The sign-out button is a **client component** that provides an interactive button for users to sign out. It handles the user interaction and calls the server action, then manages navigation and component refresh after sign-out.
```tsx src/components/SignOutButton.tsx
"use client";
import { useRouter } from "next/navigation";
import { signOut } from "../lib/nhost/actions";
export default function SignOutButton() {
const router = useRouter();
const handleSignOut = async () => {
try {
await signOut();
router.push("/");
router.refresh(); // Refresh to update server components
} catch (err) {
console.error("Error signing out:", err);
}
};
return (
<button
type="button"
onClick={handleSignOut}
className="nav-link nav-button"
>
Sign Out
</button>
);
}
```
</Tab>
<Tab title="Server Action">
The sign-out server action handles the authentication logic securely on the server side. It retrieves the current session, calls Nhost's sign-out method with the refresh token, and redirects the user to the home page after successful sign-out.
```tsx src/lib/nhost/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createNhostClient } from "./server";
export async function signOut() {
try {
const nhost = await createNhostClient();
const session = nhost.getUserSession();
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
} catch (err) {
console.error("Error signing out:", err);
throw err;
}
redirect("/");
}
```
</Tab>
</Tabs>
</Step>
<Step>
### Update Navigation Component
In this step, we'll update the server-side navigation component that shows different links based on the user's authentication state. The navigation will display "Sign In" and "Sign Up" links for unauthenticated users, and "Profile" and "Sign Out" for authenticated users.
```tsx src/components/Navigation.tsx lines highlight={3,26,30-35}
import Link from "next/link";
import { createNhostClient } from "../lib/nhost/server";
import SignOutButton from "./SignOutButton";
export default async function Navigation() {
const nhost = await createNhostClient();
const session = nhost.getUserSession();
return (
<nav className="navigation">
<div className="nav-container">
<Link href="/" className="nav-logo">
Nhost Next.js Demo
</Link>
<div className="nav-links">
<Link href="/" className="nav-link">
Home
</Link>
{session ? (
<>
<Link href="/profile" className="nav-link">
Profile
</Link>
<SignOutButton />
</>
) : (
<>
<Link href="/signin" className="nav-link">
Sign In
</Link>
<Link href="/signup" className="nav-link">
Sign Up
</Link>
</>
)}
</div>
</div>
</nav>
);
}
```
</Step>
<Step>
### Update Public Routes in Middleware
In this step, we'll configure the middleware to allow access to authentication-related routes without requiring authentication. This ensures that users can access sign-in, sign-up, and email verification pages even when not logged in.
```tsx src/middleware.ts lines highlight={6}
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { handleNhostMiddleware } from "./lib/nhost/server";
// Define public routes that don't require authentication
const publicRoutes = ["/", "/signin", "/signup", "/verify", "/verify/error"];
export async function middleware(request: NextRequest) {
// Create a response that we'll modify as needed
const response = NextResponse.next();
// Get the current path
const path = request.nextUrl.pathname;
// Check if this is a public route or a public asset
const isPublicRoute = publicRoutes.some(
(route) => path === route || path.startsWith(`${route}/`),
);
// Handle Nhost authentication and token refresh
// Always call this to ensure session is up-to-date
// even for public routes, so that session changes are detected
const session = await handleNhostMiddleware(request, response);
// If it's a public route, allow access without checking auth
if (isPublicRoute) {
return response;
}
// If no session and not a public route, redirect to signin
if (!session) {
const homeUrl = new URL("/", request.url);
return NextResponse.redirect(homeUrl);
}
// Session exists, allow access to protected route
return response;
}
// Define which routes this middleware should run on
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public files (public directory)
*/
"/((?!_next/static|_next/image|favicon.ico|public).*)",
],
};
```
</Step>
<Step>
### Run and Test the Application
Start your Next.js development server and test the complete authentication flow to ensure everything works properly.
```bash
npm run dev
```
Things to try out:
1. **Email Verification Flow**: Try signing up with a new email address. Check your email for the verification link and click it. The verification route handler will process the token and redirect you to your profile.
2. **Sign In/Out Flow**: Try signing out and then signing back in with the same credentials using the server actions.
3. **Server-Side Navigation**: Notice how navigation links change based on authentication state - the navigation component is rendered server-side and shows different content based on the session.
4. **Route Protection**: Try accessing protected routes while logged out to see the middleware-based protection in action.
5. **Cross-Tab Consistency**: Open multiple tabs and test signing out from one tab. Unlike client-side React apps, you'll need to refresh or navigate to see changes in other tabs due to server-side rendering.
</Step>
</Steps>
## Key Features Demonstrated
<AccordionGroup>
<Accordion title="Server Components & Actions" icon="server">
Full authentication flow using Next.js App Router with server components and server actions for secure, server-side processing.
</Accordion>
<Accordion title="Route Handlers" icon="route">
Custom `/verify` Route Handler that securely processes email verification tokens server-side with proper error handling.
</Accordion>
<Accordion title="Client/Server Separation" icon="arrows-split-up-and-left">
Clear separation between server components for rendering and client components for interactivity, following Next.js best practices.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling with URL-based error states and dedicated error pages for different failure scenarios.
</Accordion>
<Accordion title="Session Management" icon="clock">
Server-side session handling with sign out functionality using server actions and proper state management.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,958 @@
---
title: GraphQL Operations in Next.js
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and Next.js
sidebarTitle: "GraphQL Operations"
icon: code
---
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions using Next.js App Router patterns. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations using server components, client components, and server actions.
<Info>
This is **Part 4** in the Full-Stack Next.js Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control using Next.js App Router with server/client component separation.
</Info>
## Full-Stack Next.js Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/nextjs/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/nextjs/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/nextjs/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/nextjs/4-graphql-operations">
**Current** - CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/nextjs/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [User Authentication part](/getting-started/tutorials/nextjs/3-user-authentication) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- **GraphQL queries and mutations** for complete CRUD operations
- **Database schema** with proper relationships and constraints
- **User permissions** for secure data access control
- **Next.js components** using server/client patterns that interact with GraphQL endpoint
- **Server actions** for secure data mutations
- **Server components** for efficient data fetching
## Step-by-Step Guide
<Steps>
<Step>
### Create the To-Dos Table
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Click on the SQL Editor
Enter the following SQL:
<Tabs>
<Tab title="SQL">
```sql
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
details text,
completed bool DEFAULT false NOT NULL,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_todos_updated_at
BEFORE UPDATE ON public.todos
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
```
</Tab>
<Tab title="UI">
![Database SQL Editor](/images/tutorials/todos/1.png)
</Tab>
</Tabs>
<Warning>
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
</Warning>
</Step>
<Step>
### Set Up Permissions
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
<Tabs>
<Tab title="Insert">
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
![Insert Permissions Configuration](/images/tutorials/todos/2.png)
</Tab>
<Tab title="Select">
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
![Select Permissions Configuration](/images/tutorials/todos/3.png)
</Tab>
<Tab title="Update">
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
![Update Permissions Configuration](/images/tutorials/todos/4.png)
</Tab>
<Tab title="Delete">
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
![Delete Permissions Configuration](/images/tutorials/todos/5.png)
</Tab>
</Tabs>
</Step>
<Step>
### Create the Todos Page System
Now let's implement the Next.js page system that uses the database we just configured. We'll create a server component for the main page, a client component for the interactive todos interface, and server actions for secure data mutations.
<Tabs>
<Tab title="Page Component">
The main todos page is a **server component** that fetches initial data server-side and renders the todos interface. This component runs on the server and provides the initial state to the client component.
```tsx src/app/todos/page.tsx
import { createNhostClient } from "../../lib/nhost/server";
import TodosClient from "./TodosClient";
// The interfaces below define the structure of our data
// They are not strictly necessary but help with type safety
// Represents a single todo item
export interface Todo {
id: string;
title: string;
details: string | null;
completed: boolean;
created_at: string;
updated_at: string;
user_id: string;
}
// This matches the GraphQL response structure for fetching todos
// Can be used as a generic type on the request method
interface GetTodos {
todos: Todo[];
}
export default async function TodosPage() {
// Fetch initial todos data server-side
const nhost = await createNhostClient();
const session = nhost.getUserSession();
let initialTodos: Todo[] = [];
let error: string | null = null;
if (session) {
try {
// Make GraphQL request to fetch todos using Nhost server client
// The query automatically filters by user_id due to Hasura permissions
const response = await nhost.graphql.request<GetTodos>({
query: `
query GetTodos {
todos(order_by: { created_at: desc }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
});
// Check for GraphQL errors in the response body
if (response.body.errors) {
error = response.body.errors[0]?.message || "Failed to fetch todos";
} else {
// Extract todos from the GraphQL response data
initialTodos = response.body?.data?.todos || [];
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to fetch todos";
}
}
return <TodosClient initialTodos={initialTodos} initialError={error} />;
}
```
</Tab>
<Tab title="Client Component">
The todos client component is a **client component** that handles all interactive functionality including form submissions, state management, and user interactions. It receives initial data from the server component and manages the client-side state.
```tsx src/app/todos/TodosClient.tsx
"use client";
import { useId, useState } from "react";
import { addTodo, deleteTodo, updateTodo } from "./actions";
import type { Todo } from "./page";
interface TodosClientProps {
initialTodos: Todo[];
initialError: string | null;
}
export default function TodosClient({
initialTodos,
initialError,
}: TodosClientProps) {
const [todos, setTodos] = useState<Todo[]>(initialTodos);
const [error, setError] = useState<string | null>(initialError);
const [newTodoTitle, setNewTodoTitle] = useState("");
const [newTodoDetails, setNewTodoDetails] = useState("");
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const titleId = useId();
const detailsId = useId();
const handleAddTodo = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTodoTitle.trim()) return;
setIsLoading(true);
try {
// Call server action to add todo
const result = await addTodo({
title: newTodoTitle.trim(),
details: newTodoDetails.trim() || null,
});
if (result.success && result.todo) {
setTodos([result.todo, ...todos]);
setNewTodoTitle("");
setNewTodoDetails("");
setShowAddForm(false);
setError(null);
} else {
setError(result.error || "Failed to add todo");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add todo");
} finally {
setIsLoading(false);
}
};
const handleUpdateTodo = async (
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
) => {
try {
// Call server action to update todo
const result = await updateTodo(id, updates);
if (result.success && result.todo) {
setTodos(
todos.map((todo) => (todo.id === id ? (result.todo ?? todo) : todo)),
);
setEditingTodo(null);
setError(null);
} else {
setError(result.error || "Failed to update todo");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update todo");
}
};
const handleDeleteTodo = async (id: string) => {
if (!confirm("Are you sure you want to delete this todo?")) return;
try {
// Call server action to delete todo
const result = await deleteTodo(id);
if (result.success) {
setTodos(todos.filter((todo) => todo.id !== id));
setError(null);
} else {
setError(result.error || "Failed to delete todo");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete todo");
}
};
const toggleComplete = async (todo: Todo) => {
await handleUpdateTodo(todo.id, { completed: !todo.completed });
};
const saveEdit = async () => {
if (!editingTodo) return;
await handleUpdateTodo(editingTodo.id, {
title: editingTodo.title,
details: editingTodo.details,
});
};
const toggleTodoExpansion = (todoId: string) => {
const newExpanded = new Set(expandedTodos);
if (newExpanded.has(todoId)) {
newExpanded.delete(todoId);
} else {
newExpanded.add(todoId);
}
setExpandedTodos(newExpanded);
};
return (
<div className="container">
<header className="page-header">
<h1 className="page-title">
My Todos
{!showAddForm && (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="add-todo-btn"
title="Add a new todo"
>
+
</button>
)}
</h1>
</header>
{error && (
<div className="error-message">
<strong>Error:</strong> {error}
</div>
)}
{showAddForm && (
<div className="todo-form-card">
<form onSubmit={handleAddTodo} className="todo-form">
<h2 className="form-title">Add New Todo</h2>
<div className="form-fields">
<div className="field-group">
<label htmlFor={titleId}>Title *</label>
<input
id={titleId}
type="text"
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
placeholder="What needs to be done?"
required
disabled={isLoading}
/>
</div>
<div className="field-group">
<label htmlFor={detailsId}>Details</label>
<textarea
id={detailsId}
value={newTodoDetails}
onChange={(e) => setNewTodoDetails(e.target.value)}
placeholder="Add some details (optional)..."
rows={3}
disabled={isLoading}
/>
</div>
<div className="form-actions">
<button
type="submit"
className="btn btn-primary"
disabled={isLoading}
>
{isLoading ? "Adding..." : "Add Todo"}
</button>
<button
type="button"
onClick={() => {
setShowAddForm(false);
setNewTodoTitle("");
setNewTodoDetails("");
}}
className="btn btn-secondary"
disabled={isLoading}
>
Cancel
</button>
</div>
</div>
</form>
</div>
)}
{!showAddForm && (
<div className="todos-list">
{todos.length === 0 ? (
<div className="empty-state">
<svg
className="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 className="empty-title">No todos yet</h3>
<p className="empty-description">
Create your first todo to get started!
</p>
</div>
) : (
todos.map((todo) => (
<div
key={todo.id}
className={`todo-card ${todo.completed ? "completed" : ""}`}
>
{editingTodo?.id === todo.id ? (
<div className="todo-edit">
<div className="edit-fields">
<div className="field-group">
<label htmlFor={`${titleId}-edit`}>Title</label>
<input
id={`${titleId}-edit`}
type="text"
value={editingTodo.title}
onChange={(e) =>
setEditingTodo({
...editingTodo,
title: e.target.value,
})
}
/>
</div>
<div className="field-group">
<label htmlFor={`${detailsId}-edit`}>Details</label>
<textarea
id={`${detailsId}-edit`}
value={editingTodo.details || ""}
onChange={(e) =>
setEditingTodo({
...editingTodo,
details: e.target.value,
})
}
rows={3}
/>
</div>
<div className="edit-actions">
<button
type="button"
onClick={saveEdit}
className="btn btn-primary"
>
✓ Save Changes
</button>
<button
type="button"
onClick={() => setEditingTodo(null)}
className="btn btn-cancel"
>
✕ Cancel
</button>
</div>
</div>
</div>
) : (
<div className="todo-content">
<div className="todo-header">
<button
type="button"
className={`todo-title-btn ${todo.completed ? "completed" : ""}`}
onClick={() => toggleTodoExpansion(todo.id)}
>
{todo.title}
</button>
<div className="todo-actions">
<button
type="button"
onClick={() => toggleComplete(todo)}
className="action-btn action-btn-complete"
title={
todo.completed
? "Mark as incomplete"
: "Mark as complete"
}
>
{todo.completed ? "↶" : "✓"}
</button>
<button
type="button"
onClick={() => setEditingTodo(todo)}
className="action-btn action-btn-edit"
title="Edit todo"
>
✏️
</button>
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="action-btn action-btn-delete"
title="Delete todo"
>
🗑️
</button>
</div>
</div>
{expandedTodos.has(todo.id) && (
<div className="todo-details">
{todo.details && (
<div
className={`todo-description ${todo.completed ? "completed" : ""}`}
>
<p>{todo.details}</p>
</div>
)}
<div className="todo-meta">
<div className="meta-dates">
<span className="meta-item">
Created:{" "}
{new Date(todo.created_at).toLocaleString()}
</span>
<span className="meta-item">
Updated:{" "}
{new Date(todo.updated_at).toLocaleString()}
</span>
</div>
{todo.completed && (
<div className="completion-badge">
<svg
className="completion-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Completed</span>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
))
)}
</div>
)}
</div>
);
}
```
</Tab>
<Tab title="Server Actions">
Server actions handle all data mutations securely on the server side. They validate permissions, execute GraphQL operations, and return type-safe responses to the client components.
```tsx src/app/todos/actions.ts
"use server";
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import { createNhostClient } from "../../lib/nhost/server";
import type { Todo } from "./page";
// Response types for server actions
type ActionResult<T = void> = {
success: boolean;
error?: string;
todo?: T;
};
// GraphQL response types
interface InsertTodoResponse {
insert_todos_one: Todo | null;
}
interface UpdateTodoResponse {
update_todos_by_pk: Todo | null;
}
interface DeleteTodoResponse {
delete_todos_by_pk: { id: string } | null;
}
export async function addTodo(data: {
title: string;
details: string | null;
}): Promise<ActionResult<Todo>> {
const { title, details } = data;
if (!title.trim()) {
return {
success: false,
error: "Title is required",
};
}
try {
const nhost = await createNhostClient();
const session = nhost.getUserSession();
if (!session) {
return {
success: false,
error: "Not authenticated",
};
}
// Execute GraphQL mutation to insert a new todo
// user_id is automatically set by Hasura based on JWT token
const response = await nhost.graphql.request<InsertTodoResponse>({
query: `
mutation InsertTodo($title: String!, $details: String) {
insert_todos_one(object: { title: $title, details: $details }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
title: title.trim(),
details: details?.trim() || null,
},
});
if (response.body.errors) {
return {
success: false,
error: response.body.errors[0]?.message || "Failed to add todo",
};
}
if (!response.body?.data?.insert_todos_one) {
return {
success: false,
error: "Failed to add todo",
};
}
return {
success: true,
todo: response.body.data.insert_todos_one,
};
} catch (err) {
const error = err as FetchError<ErrorResponse>;
return {
success: false,
error: `Failed to add todo: ${error.message}`,
};
}
}
export async function updateTodo(
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
): Promise<ActionResult<Todo>> {
if (!id) {
return {
success: false,
error: "Todo ID is required",
};
}
try {
const nhost = await createNhostClient();
const session = nhost.getUserSession();
if (!session) {
return {
success: false,
error: "Not authenticated",
};
}
// Execute GraphQL mutation to update an existing todo by primary key
// Hasura permissions ensure users can only update their own todos
const response = await nhost.graphql.request<UpdateTodoResponse>({
query: `
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
id,
updates,
},
});
if (response.body.errors) {
return {
success: false,
error: response.body.errors[0]?.message || "Failed to update todo",
};
}
if (!response.body?.data?.update_todos_by_pk) {
return {
success: false,
error: "Failed to update todo",
};
}
return {
success: true,
todo: response.body.data.update_todos_by_pk,
};
} catch (err) {
const error = err as FetchError<ErrorResponse>;
return {
success: false,
error: `Failed to update todo: ${error.message}`,
};
}
}
export async function deleteTodo(id: string): Promise<ActionResult> {
if (!id) {
return {
success: false,
error: "Todo ID is required",
};
}
try {
const nhost = await createNhostClient();
const session = nhost.getUserSession();
if (!session) {
return {
success: false,
error: "Not authenticated",
};
}
// Execute GraphQL mutation to delete a todo by primary key
// Hasura permissions ensure users can only delete their own todos
const response = await nhost.graphql.request<DeleteTodoResponse>({
query: `
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
variables: {
id,
},
});
if (response.body.errors) {
return {
success: false,
error: response.body.errors[0]?.message || "Failed to delete todo",
};
}
return {
success: true,
};
} catch (err) {
const error = err as FetchError<ErrorResponse>;
return {
success: false,
error: `Failed to delete todo: ${error.message}`,
};
}
}
```
</Tab>
</Tabs>
</Step>
<Step>
### Update Navigation Component
Add the todos page to your application navigation by updating the Navigation component to include a link to the todos page.
```tsx src/components/Navigation.tsx lines highlight={23-25}
import Link from "next/link";
import { createNhostClient } from "../lib/nhost/server";
import SignOutButton from "./SignOutButton";
export default async function Navigation() {
const nhost = await createNhostClient();
const session = nhost.getUserSession();
return (
<nav className="navigation">
<div className="nav-container">
<Link href="/" className="nav-logo">
Nhost Next.js Demo
</Link>
<div className="nav-links">
<Link href="/" className="nav-link">
Home
</Link>
{session ? (
<>
<Link href="/todos" className="nav-link">
Todos
</Link>
<Link href="/profile" className="nav-link">
Profile
</Link>
<SignOutButton />
</>
) : (
<>
<Link href="/signin" className="nav-link">
Sign In
</Link>
<Link href="/signup" className="nav-link">
Sign Up
</Link>
</>
)}
</div>
</div>
</nav>
);
}
```
</Step>
<Step>
### Test Your Complete Application
Run your Next.js application and test all the functionality:
```bash
npm run dev
```
Things to try out:
1. **Server-Side Rendering**: Notice how the todos are loaded server-side on initial page load, providing faster initial rendering
2. **Authentication Integration**: Try signing in and out and see how the Todos page is only available when authenticated through middleware protection
3. **CRUD Operations**: Create, view, edit, complete, and delete todos. Notice how server actions handle mutations while maintaining type safety
4. **Multi-User Isolation**: Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
5. **Real-time Updates**: Unlike client-only React apps, changes will be persisted immediately through server actions and reflected in the optimistic UI updates
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Database Schema" icon="database">
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
</Accordion>
<Accordion title="GraphQL API" icon="webhook">
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
</Accordion>
<Accordion title="Row-Level Security" icon="shield-check">
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
</Accordion>
<Accordion title="Server/Client Architecture" icon="server">
Next.js App Router patterns with server components for data fetching, client components for interactivity, and server actions for mutations.
</Accordion>
<Accordion title="Server-Side Data Fetching" icon="database">
Initial todos loaded server-side for improved performance and SEO, with client-side state management for optimal user experience.
</Accordion>
<Accordion title="Type-Safe Server Actions" icon="shield">
Secure server-side mutations with comprehensive error handling and type safety throughout the data flow.
</Accordion>
<Accordion title="Rich Interface" icon="sparkles">
Expandable todo items, inline editing, completion status, and detailed timestamps with responsive design.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,822 @@
---
title: File Uploads in Next.js
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and Next.js
sidebarTitle: "File Uploads"
icon: upload
---
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a Next.js application using server and client components.
<Info>
This is **Part 5** in the Full-Stack Next.js Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application using Next.js App Router patterns.
</Info>
## Full-Stack Next.js Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/nextjs/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/nextjs/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/nextjs/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/nextjs/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/nextjs/5-file-uploads">
**Current** - File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [GraphQL Operations part](/getting-started/tutorials/nextjs/4-graphql-operations) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- A **personal bucket** so users can upload their own private files
- **File upload functionality**
- **File management interface** for viewing and deleting files
- **Security permissions** ensuring users can only access their own files
## Step-by-Step Guide
<Steps>
<Step>
### Create a Personal Storage Bucket
First, we'll create a storage bucket where users can upload their personal files.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Change to **schema.storage**, then buckets
3. Now click on `+ Insert` on the top right corner.
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
![Create bucket](/images/tutorials/uploads/1.png)
</Step>
<Step>
### Configure Storage Permissions
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
<Tabs>
<Tab title="Upload">
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
![upload files permissions](/images/tutorials/uploads/2.png)
</Tab>
<Tab title="Download">
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
![download files permissions](/images/tutorials/uploads/3.png)
</Tab>
<Tab title="Delete">
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
![delete files permissions](/images/tutorials/uploads/4.png)
</Tab>
</Tabs>
<Info>
You can read more about storage permissions [here](/products/storage/overview#permissions)
</Info>
</Step>
<Step>
### Create the File Upload System
Now let's implement the Next.js file upload functionality using server actions, API routes, and client components. We'll use server actions for upload and delete operations, and API routes for file viewing and downloading.
<Tabs>
<Tab title="Server Actions">
First, let's create server actions to handle file upload and delete operations. Server actions run on the server and provide a secure way to handle file operations.
```tsx src/app/files/actions.ts
"use server";
import type { FileMetadata } from "@nhost/nhost-js/storage";
import { revalidatePath } from "next/cache";
import { createNhostClient } from "../../lib/nhost/server";
export interface ActionResult<T> {
success: boolean;
error?: string;
data?: T;
}
export interface UploadFileData {
file: FileMetadata;
message: string;
}
export interface DeleteFileData {
message: string;
}
export async function uploadFileAction(
formData: FormData,
): Promise<ActionResult<UploadFileData>> {
try {
const nhost = await createNhostClient();
const file = formData.get("file") as File;
if (!file) {
return { success: false, error: "Please select a file to upload" };
}
const response = await nhost.storage.uploadFiles({
"bucket-id": "personal",
"file[]": [file],
});
const uploadedFile = response.body.processedFiles?.[0];
if (!uploadedFile) {
return { success: false, error: "Failed to upload file" };
}
revalidatePath("/files");
return {
success: true,
data: {
file: uploadedFile,
message: "File uploaded successfully!",
},
};
} catch (error) {
const message =
error instanceof Error ? error.message : "An unknown error occurred";
return { success: false, error: `Failed to upload file: ${message}` };
}
}
export async function deleteFileAction(
fileId: string,
fileName: string,
): Promise<ActionResult<DeleteFileData>> {
try {
const nhost = await createNhostClient();
if (!fileId) {
return { success: false, error: "File ID is required" };
}
await nhost.storage.deleteFile(fileId);
revalidatePath("/files");
return {
success: true,
data: { message: `${fileName} deleted successfully` },
};
} catch (error) {
const message =
error instanceof Error ? error.message : "An unknown error occurred";
return {
success: false,
error: `Failed to delete ${fileName}: ${message}`,
};
}
}
```
</Tab>
<Tab title="API Routes">
Create an API route to handle file viewing and downloading. This route will fetch the file from Nhost storage and return it with appropriate headers to the client.
```tsx src/app/files/download/[fileId]/route.ts
import { type NextRequest, NextResponse } from "next/server";
import { createNhostClient } from "../../../../lib/nhost/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> },
) {
try {
const nhost = await createNhostClient();
const { fileId } = await params;
const fileName = request.nextUrl.searchParams.get("fileName") || "file";
const download = request.nextUrl.searchParams.get("download") === "true";
if (!fileId) {
return NextResponse.json(
{ error: "File ID is required" },
{ status: 400 },
);
}
const response = await nhost.storage.getFile(fileId);
if (!response.body) {
return NextResponse.json({ error: "File not found" }, { status: 404 });
}
// Get the file content as an array buffer
const arrayBuffer = await response.body.arrayBuffer();
// Determine content disposition based on download parameter
const contentDisposition = download
? `attachment; filename="${fileName}"`
: `inline; filename="${fileName}"`;
// Create the response with appropriate headers
return new NextResponse(arrayBuffer, {
status: 200,
headers: {
"Content-Type": response.body.type || "application/octet-stream",
"Content-Disposition": contentDisposition,
"Content-Length": arrayBuffer.byteLength.toString(),
"Cache-Control":
response.headers.get("Cache-Control") ||
"public, max-age=31536000, immutable",
Etag: response.headers.get("ETag") || "",
"Last-Modified": response.headers.get("Last-Modified") || "",
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "An unknown error occurred";
return NextResponse.json(
{ error: `Failed to access file: ${message}` },
{ status: 500 },
);
}
}
```
</Tab>
<Tab title="Page Component">
The main files page is a **server component** that fetches the user's files on the server and renders the file management interface. This component runs on the server and can directly access the Nhost client for data fetching.
```tsx src/app/files/page.tsx
import type { FileMetadata } from "@nhost/nhost-js/storage";
import { createNhostClient } from "../../lib/nhost/server";
import FilesClient from "./FilesClient";
interface GetFilesResponse {
files: FileMetadata[];
}
export default async function FilesPage() {
const nhost = await createNhostClient();
// Fetch files on the server
let files: FileMetadata[] = [];
let error: string | null = null;
try {
// Use GraphQL to fetch files from the storage system
// Files are automatically filtered by user permissions
const response = await nhost.graphql.request<GetFilesResponse>({
query: `query GetFiles {
files {
id
name
size
mimeType
bucketId
uploadedByUserId
}
}`,
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch files",
);
}
files = response.body.data?.files || [];
} catch (err) {
error = `Failed to load files: ${err instanceof Error ? err.message : "Unknown error"}`;
console.error("Error fetching files:", err);
}
return (
<div className="container">
<header className="page-header">
<h1 className="page-title">File Upload</h1>
</header>
{/* Pass the server-fetched files to the client component */}
<FilesClient initialFiles={files} serverError={error} />
</div>
);
}
```
</Tab>
<Tab title="Client Component">
The files client component is a **client component** that handles all user interactions including file uploads, viewing, and deletion. It uses server actions for upload and delete operations, and API routes for file viewing and downloading.
```tsx src/app/files/FilesClient.tsx
"use client";
import type { FileMetadata } from "@nhost/nhost-js/storage";
import { useRef, useState, useTransition } from "react";
import { deleteFileAction, uploadFileAction } from "./actions";
interface FilesClientProps {
initialFiles: FileMetadata[];
serverError: string | null;
}
interface DeleteStatus {
message: string;
isError: boolean;
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
}
export default function FilesClient({
initialFiles,
serverError,
}: FilesClientProps) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [_isPending, startTransition] = useTransition();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState<boolean>(false);
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
const [error, setError] = useState<string | null>(serverError);
const [files, setFiles] = useState<FileMetadata[]>(initialFiles);
const [viewingFile, setViewingFile] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
if (file) {
setSelectedFile(file);
setError(null);
setUploadResult(null);
}
}
};
const handleUpload = async (): Promise<void> => {
if (!selectedFile) {
setError("Please select a file to upload");
return;
}
setUploading(true);
setError(null);
startTransition(async () => {
try {
const formData = new FormData();
formData.append("file", selectedFile);
const result = await uploadFileAction(formData);
if (result.success && result.data) {
const file = result.data.file;
setUploadResult(file);
// Clear the form
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
// Update the files list
setFiles((prevFiles) => [file, ...prevFiles]);
// Clear success message after 3 seconds
setTimeout(() => {
setUploadResult(null);
}, 3000);
} else {
setError(result.error || "Failed to upload file");
}
} catch (err: unknown) {
const message = (err as Error).message || "An unknown error occurred";
setError(`Failed to upload file: ${message}`);
} finally {
setUploading(false);
}
});
};
const handleViewFile = async (
fileId: string,
fileName: string,
mimeType: string,
): Promise<void> => {
setViewingFile(fileId);
try {
// Handle different file types appropriately
if (
mimeType.startsWith("image/") ||
mimeType === "application/pdf" ||
mimeType.startsWith("text/") ||
mimeType.startsWith("video/") ||
mimeType.startsWith("audio/")
) {
// Use download route for viewable files (inline viewing)
const viewUrl = `/files/download/${fileId}?fileName=${encodeURIComponent(fileName)}`;
window.open(viewUrl, "_blank");
} else {
// Use download route for downloads (force download)
const downloadUrl = `/files/download/${fileId}?fileName=${encodeURIComponent(fileName)}&download=true`;
const link = document.createElement("a");
link.href = downloadUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Show download confirmation
const newWindow = window.open("", "_blank", "width=400,height=200");
if (newWindow) {
newWindow.document.documentElement.innerHTML = `
<head>
<title>File Download</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
</style>
</head>
<body>
<h3>Downloading: ${fileName}</h3>
<p>Your download has started. You can close this window.</p>
</body>
`;
}
}
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
setError(`Failed to view file: ${message}`);
console.error("Error viewing file:", err);
} finally {
setViewingFile(null);
}
};
const handleDeleteFile = async (fileId: string): Promise<void> => {
if (!fileId || deleting) return;
setDeleting(fileId);
setError(null);
setDeleteStatus(null);
const fileToDelete = files.find((file) => file.id === fileId);
const fileName = fileToDelete?.name || "File";
startTransition(async () => {
try {
const result = await deleteFileAction(fileId, fileName);
if (result.success && result.data) {
setDeleteStatus({
message: result.data.message,
isError: false,
});
// Remove from local state
setFiles(files.filter((file) => file.id !== fileId));
// Clear success message after 3 seconds
setTimeout(() => {
setDeleteStatus(null);
}, 3000);
} else {
setDeleteStatus({
message: result.error || `Failed to delete ${fileName}`,
isError: true,
});
}
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
setDeleteStatus({
message: `Failed to delete ${fileName}: ${message}`,
isError: true,
});
console.error("Error deleting file:", err);
} finally {
setDeleting(null);
}
});
};
return (
<>
<div className="form-card">
<h2 className="form-title">Upload a File</h2>
<div className="field-group">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
border: 0,
}}
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
className="btn btn-secondary file-upload-btn"
onClick={() => fileInputRef.current?.click()}
>
<svg
width="40"
height="40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="Upload file"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p>Click to select a file</p>
{selectedFile && (
<p className="file-upload-info">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
)}
</button>
</div>
{error && <div className="error-message">{error}</div>}
{uploadResult && (
<div className="success-message">File uploaded successfully!</div>
)}
<button
type="button"
onClick={handleUpload}
disabled={!selectedFile || uploading}
className="btn btn-primary"
style={{ width: "100%" }}
>
{uploading ? "Uploading..." : "Upload File"}
</button>
</div>
<div className="form-card">
<h2 className="form-title">Your Files</h2>
{deleteStatus && (
<div
className={
deleteStatus.isError ? "error-message" : "success-message"
}
>
{deleteStatus.message}
</div>
)}
{files.length === 0 ? (
<div className="empty-state">
<svg
className="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<h3 className="empty-title">No files yet</h3>
<p className="empty-description">
Upload your first file to get started!
</p>
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table className="file-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id}>
<td className="file-name">{file.name}</td>
<td className="file-meta">{file.mimeType}</td>
<td className="file-meta">
{formatFileSize(file.size || 0)}
</td>
<td>
<div className="file-actions">
<button
type="button"
onClick={() =>
handleViewFile(
file.id || "unknown",
file.name || "unknown",
file.mimeType || "unknown",
)
}
disabled={viewingFile === file.id}
className="action-btn action-btn-edit"
title="View File"
>
{viewingFile === file.id ? "⏳" : "👁️"}
</button>
<button
type="button"
onClick={() => handleDeleteFile(file.id || "unknown")}
disabled={deleting === file.id}
className="action-btn action-btn-delete"
title="Delete File"
>
{deleting === file.id ? "⏳" : "🗑️"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}
```
</Tab>
</Tabs>
</Step>
<Step>
### Update Navigation Component
Add a link to the files page in the server-side navigation component.
```tsx src/components/Navigation.tsx lines highlight={26-28}
import Link from "next/link";
import { createNhostClient } from "../lib/nhost/server";
import SignOutButton from "./SignOutButton";
export default async function Navigation() {
const nhost = await createNhostClient();
const session = nhost.getUserSession();
return (
<nav className="navigation">
<div className="nav-container">
<Link href="/" className="nav-logo">
Nhost Next.js Demo
</Link>
<div className="nav-links">
<Link href="/" className="nav-link">
Home
</Link>
{session ? (
<>
<Link href="/todos" className="nav-link">
Todos
</Link>
<Link href="/files" className="nav-link">
Files
</Link>
<Link href="/profile" className="nav-link">
Profile
</Link>
<SignOutButton />
</>
) : (
<>
<Link href="/signin" className="nav-link">
Sign In
</Link>
<Link href="/signup" className="nav-link">
Sign Up
</Link>
</>
)}
</div>
</div>
</nav>
);
}
```
</Step>
<Step>
### Test Your File Upload System
Run your Next.js development server and test all the functionality:
```bash
npm run dev
```
Things to try out:
1. **Server-Side Protection**: Try accessing `/files` while logged out - the middleware will redirect you to the home page.
2. **File Upload Flow**: Sign in and navigate to the Files page using the navigation link. The server component will fetch your existing files, and the client component will handle uploads.
3. **Upload Different File Types**: Upload various file types (images, documents, PDFs, etc.) to test the file type handling.
4. **View and Delete Files**: Test the view functionality for different file types - images and PDFs will open in new tabs, while other files will download.
5. **User Isolation**: Sign in with different accounts to verify users can only see their own files due to storage permissions.
6. **Server-Side Rendering**: Notice how your file list loads immediately on page refresh since it's fetched on the server, unlike client-only React apps.
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Server/Client Architecture" icon="server">
Clear separation between server components for data fetching and client components for user interactions, following Next.js App Router best practices.
</Accordion>
<Accordion title="Storage Bucket & Permissions" icon="bucket">
Dedicated personal storage bucket with proper configuration for user file isolation, leveraging Nhost's permission system.
</Accordion>
<Accordion title="Server-Side Data Fetching" icon="database">
Files are fetched on the server and passed to client components, providing immediate data on page load without loading states.
</Accordion>
<Accordion title="File Upload Interface" icon="upload">
User-friendly upload interface with file selection, preview, and progress feedback using client-side interactions.
</Accordion>
<Accordion title="File Management" icon="folder">
Complete file listing with metadata, viewing capabilities, and deletion functionality with proper state management.
</Accordion>
<Accordion title="File Type Handling" icon="file">
Intelligent handling of different file types with appropriate viewing/download behavior for various media types.
</Accordion>
<Accordion title="Route Protection" icon="shield">
Automatic route protection through Next.js middleware, ensuring only authenticated users can access file upload functionality.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling with server-side error detection and client-side user feedback for all operations.
</Accordion>
</AccordionGroup>

View File

@@ -1,497 +0,0 @@
---
title: Build a Todo Manager with React
description: Learn how to use Nhost with React
sidebarTitle: React
icon: react
---
In this tutorial, you will build a simple **Todo Manager** application with Nhost and React. Along the way you will interact with the Database, Authentication, and Storage services.
The Todo Manager will allow users to see public `todos` and sign in using a Magic Link to manage their own `todos` with attachments.
<CardGroup cols={3}>
<Card title="Database">
To store todos
</Card>
<Card title="Auth">
To sign in users
</Card>
<Card title="Storage">
To store attachments
</Card>
</CardGroup>
## Setup Nhost Backend
In this section, you will create and setup your first Nhost project.
### Create project
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
- Dedicated PostgreSQL
- Realtime APIs over your data
- Authentication for managing your users
- Storage for handling files
### Create table `todos`
On the project's dashboard, navigate to **Database** and create a new table called `todos`.
![Database](/images/tutorials/todos-react-database.png)
You can either copy and paste the following SQL into the SQL Editor, **Database -> SQL Editor**, or manually create the table by clicking on **New Table**.
<Tabs>
<Tab title="SQL Editor">
Copy and paste the following SQL into the SQL Editor and press **Run**.
<Note>Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs</Note>
```sql SQL
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
completed bool DEFAULT 'false' NOT NULL,
file_id uuid,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (file_id) REFERENCES storage.files (id) ON UPDATE SET NULL ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE SET NULL ON DELETE SET NULL
);
```
</Tab>
<Tab title="UI">
Click on **New Table** and fill in the details for the `todos` table as shown.
![New Table](/images/tutorials/todos-react-database-new-table.png)
</Tab>
</Tabs>
You should now see a new table called `todos` on the left panel, below **New Table**.
### Set permissions for todos
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **...**, followed by **Edit Permissions**.
You will set permissions for the `user` role and actions `insert`, `select`, `update`, and `delete`.
<Tabs>
<Tab title="insert">
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
![User Insert](/images/tutorials/todos-react-permissions-insert.png)
</Tab>
<Tab title="select">
Click on the right cell for the `user` role and action `select` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-select.png)
</Tab>
<Tab title="update">
Click on the right cell for the `user` role and action `update` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-update.png)
</Tab>
<Tab title="delete">
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
![User Delete](/images/tutorials/todos-react-permissions-delete.png)
</Tab>
</Tabs>
### Set permissions for files
The `files` table is managed by Nhost and is defined on the `storage` schema. Click on the dropdown right next to `schema.public` and choose `schema.storage`.
With the `files` table selected, click on **...**, followed by **Edit Permissions**.
As before, we want to set permissions for the `user` role and actions `insert`, `select`, `delete`.
<Tabs>
<Tab title="insert">
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
![User Insert](/images/tutorials/todos-react-permissions-files-insert.png)
</Tab>
<Tab title="select">
Click on the right cell for the `user` role and action `select` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-files-select.png)
</Tab>
<Tab title="delete">
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
![User Delete](/images/tutorials/todos-react-permissions-files-delete.png)
</Tab>
</Tabs>
### Enable Sign In with Magic Link
To enable Magic Links, navigate to your project's **Settings -> Sign-In Methods**, toggle Magic Link, and save.
### Recap
<Steps>
<Step title="Nhost project created">
</Step>
<Step title="Database todos created">
</Step>
<Step title="Permissions set for todos and files">
</Step>
<Step title="Magic Link enabled">
</Step>
</Steps>
## Setup React Application
Now that we have Nhost configured, let's move on to setup the React application and the Nhost client.
### Create React Application
Run the following command in your terminal to create a React application using Vite.
```bash Terminal
npm create vite@latest nhost-react -- --template react
```
### Install Nhost React package
To install Nhost's React package, run the following command.
```bash Terminal
cd nhost-react && npm install @nhost/react
```
#### Configure the Nhost Client
Create a new file, `./src/lib/nhost.js`, with the following code to create a Nhost client. Replace `<SUBDOMAIN>` and `<REGION>` with the values from the project created earlier.
```ts ./src/lib/nhost.ts
import { NhostClient } from "@nhost/react";
export const nhost = new NhostClient({
subdomain: "<SUBDOMAIN>",
region: "<REGION>"
});
```
<Info>The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project Info**</Info>
### Setup Sign In Component
It is time to setup a new React component to handle the login functionality. Users will be able to sign in using a Magic Link.
Create a new file `./src/signin.jsx` with the following content:
```js ./src/signin.jsx
import { useState } from 'react'
import { useSignInEmailPasswordless } from '@nhost/react'
export default function SignIn() {
const [loading, setLoading] = useState(false)
const [email, setEmail] = useState('')
const { signInEmailPasswordless, error } = useSignInEmailPasswordless()
const handleSignIn = async (event) => {
event.preventDefault()
setLoading(true)
const { error } = await signInEmailPasswordless(email)
if (error) {
console.error({ error })
return
}
setLoading(false)
alert('Magic Link Sent!')
}
return (
<div>
<h1>Todo Manager</h1>
<p>powered by Nhost and React</p>
<form onSubmit={handleSignIn}>
<div>
<input
type="email"
placeholder="Your email"
value={email}
required={true}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<button disabled={loading}>
{loading ? <span>Loading</span> : <span>Send me a Magic Link!</span>}
</button>
</div>
{error && <p>{error.message}</p>}
</form>
</div>
)
}
```
### Setup `Todos` Component
Now that users can sign in, let's move on and create the authenticated page that lists a user's todos and has a form for managing todos with attachments.
```js ./src/todos.jsx
import { useState, useEffect } from 'react'
import { useNhostClient, useFileUpload } from '@nhost/react'
const deleteTodo = `
mutation($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`
const createTodo = `
mutation($title: String!, $file_id: uuid) {
insert_todos_one(object: {title: $title, file_id: $file_id}) {
id
}
}
`
const getTodos = `
query {
todos {
id
title
file_id
completed
}
}
`
export default function Todos() {
const [loading, setLoading] = useState(true)
const [todos, setTodos] = useState([])
const [todoTitle, setTodoTitle] = useState('')
const [todoAttachment, setTodoAttachment] = useState(null)
const [fetchAll, setFetchAll] = useState(false)
const nhostClient = useNhostClient()
const { upload } = useFileUpload()
useEffect(() => {
async function fetchTodos() {
setLoading(true)
const { data, error } = await nhostClient.graphql.request(getTodos)
if (error) {
console.error({ error })
return
}
setTodos(data.todos)
setLoading(false)
}
fetchTodos()
return () => {
setFetchAll(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchAll])
const handleCreateTodo = async (e) => {
e.preventDefault()
let todo = { title: todoTitle }
if (todoAttachment) {
const { id, error } = await upload({
file: todoAttachment,
name: todoAttachment.name
})
if (error) {
console.error({ error })
return
}
todo.file_id = id
}
const { error } = await nhostClient.graphql.request(createTodo, todo)
if (error) {
console.error({ error })
}
setTodoTitle('')
setTodoAttachment(null)
setFetchAll(true)
}
const handleDeleteTodo = async (id) => {
if (!window.confirm('Are you sure you want to delete this TODO?')) {
return
}
const todo = todos.find((todo) => todo.id === id)
if (todo.file_id) {
await nhostClient.storage.delete({ fileId: todo.file_id })
}
const { error } = await nhostClient.graphql.request(deleteTodo, { id })
if (error) {
console.error({ error })
}
setFetchAll(true)
}
const completeTodo = async (id) => {
const { error } = await nhostClient.graphql.request(
`
mutation($id: uuid!) {
update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
completed
}
}
`,
{ id }
)
if (error) {
console.error({ error })
}
setFetchAll(true)
}
const openAttachment = async (todo) => {
const { presignedUrl, error } = await nhostClient.storage.getPresignedUrl({
fileId: todo.file_id
})
if (error) {
console.error({ error })
return
}
window.open(presignedUrl.url, '_blank')
}
return (
<>
<div className="container">
<div className="form-section">
<h2>Add a new TODO</h2>
<form onSubmit={handleCreateTodo}>
<div className="input-group">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
placeholder="Title"
value={todoTitle}
onChange={(e) => setTodoTitle(e.target.value)}
/>
</div>
<div className="input-group">
<label htmlFor="file">File (optional)</label>
<input id="file" type="file" onChange={(e) => setTodoAttachment(e.target.files[0])} />
</div>
<div className="submit-group">
<button type="submit" disabled={!todoTitle}>
Add Todo
</button>
</div>
</form>
</div>
<div className="todos-section">
{(!loading &&
todos.map((todo) => (
<div className="todo-item" key={todo.id ?? 0}>
<input
type="checkbox"
checked={todo.completed}
disabled={todo.completed}
id={`todo-${todo.id}`}
onChange={() => completeTodo(todo.id)}
/>
{todo.file_id && (
<span>
<a onClick={() => openAttachment(todo)}> Open Attachment</a>
</span>
)}
<label htmlFor={`todo-${todo.id}`} className="todo-title">
{todo.completed && <s>{todo.title}</s>}
{!todo.completed && todo.title}
</label>
<button type="button" onClick={() => handleDeleteTodo(todo.id)}>
Delete
</button>
</div>
))) || (
<div className="todo-item">
<label className="todo-title">Loading...</label>
</div>
)}
</div>
</div>
<div className="sign-out-section">
<button type="button" onClick={() => nhostClient.auth.signOut()}>
Sign Out
</button>
</div>
</>
)
}
```
With both `SignIn` and `Todos` in place, update `./src/App.jsx` to use the new components:
```js ./src/App.jsx
import './App.css'
import { NhostProvider } from '@nhost/react'
import { nhost } from './lib/nhost.js'
import SignIn from './signin'
import Todos from './todos'
import { useEffect, useState } from 'react'
function App() {
const [session, setSession] = useState(null)
useEffect(() => {
setSession(nhost.auth.getSession())
nhost.auth.onAuthStateChanged((_, session) => {
setSession(session)
})
}, [])
return (
<NhostProvider nhost={nhost}>
{session ? <Todos session={session} /> : <SignIn />}
</NhostProvider>
)
}
export default App
```
## The End
Run the Todo Manager with:
```bash Terminal
npm run dev -- --open --port 3000
```
Open your browser on [localhost:3000](localhost:3000) to see your new application in action.

View File

@@ -0,0 +1,116 @@
---
title: Create Your Nhost Project
description: Learn how to create and set up a new Nhost project to get started building your React application
sidebarTitle: Create Project
icon: plus
---
Welcome to the **Full-Stack React Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete React application with Nhost that demonstrates authentication, database operations, and file management.
## About This Tutorial Series
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern web applications with Nhost and React. By the end of the series, you'll have built a fully functional application featuring:
- **User Authentication** - Complete sign up, sign in, and email verification flow
- **Todo Management** - Users can create, update, delete, and mark todos as complete
- **File Uploads** - Users can upload and manage files with proper permissions
- **Protected Routes** - Secure areas that only authenticated users can access
<Info>
This is **Part 1** in the Full-Stack React Development with Nhost series. This part sets up the foundation by creating your Nhost project and understanding the series structure.
</Info>
## Full-Stack React Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
**Current** - Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## What You'll Learn
Throughout this series, you'll master:
- Setting up and configuring Nhost projects
- Implementing secure authentication flows
- Building protected routes with React Router
- Performing GraphQL queries and mutations
- Managing file uploads and storage
- Configuring database permissions and security
- Building responsive React interfaces
## Prerequisites
- Node.js 20+ installed on your machine
- Basic knowledge of React and JavaScript
- Understanding of modern web development concepts
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
## Step-by-Step Guide
<Steps>
<Step>
### Sign Up or Log in
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
![sign up/sign in](/images/tutorials/create-nhost-project/1.png)
</Step>
<Step>
### Create a New Project
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
![2](/images/tutorials/create-nhost-project/2.png)
</Step>
<Step>
### Take note of your project subdomain and region
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
![3](/images/tutorials/create-nhost-project/3.png)
</Step>
</Steps>
## What's Next?
With your Nhost project created, you now have access to:
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
- [**File Storage**](/products/storage/overview) - For uploading and managing files
- [**Functions**](/products/functions/overview) - For running serverless functions
In the [next tutorial](/getting-started/tutorials/react/2-protected-routes), you'll start building your React application and learn how to protect routes based on user authentication status.
<Tip>
Keep your project subdomain and region handy - you'll need them throughout the series to connect your React application to the Nhost backend.
</Tip>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,647 @@
---
title: User Authentication in React
description: Learn how to implement user authentication in a React application using Nhost
sidebarTitle: "User Authentication"
icon: user
---
This tutorial part builds upon the [Protected Routes part](/getting-started/tutorials/react/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
<Info>
This is **Part 3** in the Full-Stack React Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling.
</Info>
## Full-Stack React Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
**Current** - Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [Protected Routes part](/getting-started/tutorials/react/2-protected-routes) first
- The project from the previous part set up and running
## Step-by-Step Guide
<Steps>
<Step>
### Create the Sign In Page
Build a comprehensive sign-in form with proper error handling and loading states. This page handles user authentication and includes special logic for post-verification sign-in.
```tsx src/pages/SignIn.tsx lines
import { useEffect, useId, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function SignIn() {
const { nhost, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const emailId = useId();
const passwordId = useId();
// Use useEffect for navigation after authentication is confirmed
useEffect(() => {
if (isAuthenticated) {
navigate("/profile");
}
}, [isAuthenticated, navigate]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
try {
// Use the signIn function from auth context
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
// If we have a session, sign in was successful
if (response.body?.session) {
navigate("/profile");
} else {
setError("Failed to sign in. Please check your credentials.");
}
} catch (err) {
const message = (err as Error).message || "Unknown error";
setError(`An error occurred during sign in: ${message}`);
} finally {
setIsLoading(false);
}
};
return (
<div>
<h1>Sign In</h1>
<form onSubmit={handleSubmit} className="auth-form">
<div className="auth-form-field">
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="auth-input"
/>
</div>
<div className="auth-form-field">
<label htmlFor={passwordId}>Password</label>
<input
id={passwordId}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="auth-input"
/>
</div>
{error && <div className="auth-error">{error}</div>}
<button
type="submit"
disabled={isLoading}
className={`auth-button secondary`}
>
{isLoading ? "Signing In..." : "Sign In"}
</button>
</form>
<div className="auth-links">
<p>
Don't have an account? <Link to="/signup">Sign Up</Link>
</p>
</div>
</div>
);
}
```
</Step>
<Step>
### Create the Sign Up Page
Implement user registration with email verification flow. This page collects user information, creates accounts, and guides users through the email verification process.
```tsx src/pages/SignUp.tsx lines
import { useEffect, useId, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function SignUp() {
const { nhost, isAuthenticated } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const displayNameId = useId();
const emailId = useId();
const passwordId = useId();
// Redirect authenticated users to profile
useEffect(() => {
if (isAuthenticated) {
navigate("/profile");
}
}, [isAuthenticated, navigate]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError(null);
setSuccess(false);
try {
const response = await nhost.auth.signUpEmailPassword({
email,
password,
options: {
displayName,
// Set the redirect URL for email verification
redirectTo: `${window.location.origin}/verify`,
},
});
if (response.body?.session) {
// Successfully signed up and automatically signed in
navigate("/profile");
} else {
// Verification email sent
setSuccess(true);
}
} catch (err) {
const message = (err as Error).message || "Unknown error";
setError(`An error occurred during sign up: ${message}`);
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<div>
<h1>Check Your Email</h1>
<div className="success-message">
<p>
We've sent a verification link to <strong>{email}</strong>
</p>
<p>
Please check your email and click the verification link to activate
your account.
</p>
</div>
<p>
<Link to="/signin">Back to Sign In</Link>
</p>
</div>
);
}
return (
<div>
<h1>Sign Up</h1>
<form onSubmit={handleSubmit} className="auth-form">
<div className="auth-form-field">
<label htmlFor={displayNameId}>Display Name</label>
<input
id={displayNameId}
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="auth-input"
/>
</div>
<div className="auth-form-field">
<label htmlFor={emailId}>Email</label>
<input
id={emailId}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="auth-input"
/>
</div>
<div className="auth-form-field">
<label htmlFor={passwordId}>Password</label>
<input
id={passwordId}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="auth-input"
/>
<small className="help-text">Minimum 8 characters</small>
</div>
{error && <div className="auth-error">{error}</div>}
<button
type="submit"
disabled={isLoading}
className={`auth-button primary`}
>
{isLoading ? "Creating Account..." : "Sign Up"}
</button>
</form>
<div className="auth-links">
<p>
Already have an account? <Link to="/signin">Sign In</Link>
</p>
</div>
</div>
);
}
```
</Step>
<Step>
### Create the Email Verification Page
Build a dedicated verification page that processes email verification tokens. This page handles the verification flow when users click the email verification link.
```tsx src/pages/Verify.tsx lines
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Verify() {
const location = useLocation();
const navigate = useNavigate();
const [status, setStatus] = useState<"verifying" | "success" | "error">(
"verifying",
);
const [error, setError] = useState<string | null>(null);
const [urlParams, setUrlParams] = useState<Record<string, string>>({});
const { nhost } = useAuth();
useEffect(() => {
// Extract the refresh token from the URL
const params = new URLSearchParams(location.search);
const refreshToken = params.get("refreshToken");
if (!refreshToken) {
// Collect all URL parameters to display for debugging
const allParams: Record<string, string> = {};
params.forEach((value, key) => {
allParams[key] = value;
});
setUrlParams(allParams);
setStatus("error");
setError("No refresh token found in URL");
return;
}
// Flag to handle component unmounting during async operations
let isMounted = true;
async function processToken(): Promise<void> {
try {
// First display the verifying message for at least a moment
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isMounted) return;
if (!refreshToken) {
// Collect all URL parameters to display
const allParams: Record<string, string> = {};
params.forEach((value, key) => {
allParams[key] = value;
});
setUrlParams(allParams);
setStatus("error");
setError("No refresh token found in URL");
return;
}
// Process the token
await nhost.auth.refreshToken({ refreshToken });
if (!isMounted) return;
setStatus("success");
// Wait to show success message briefly, then redirect
setTimeout(() => {
if (isMounted) navigate("/profile");
}, 1500);
} catch (err) {
const message = (err as Error).message || "Unknown error";
if (!isMounted) return;
setStatus("error");
setError(`An error occurred during verification: ${message}`);
}
}
processToken();
// Cleanup function
return () => {
isMounted = false;
};
}, [location.search, navigate, nhost.auth]);
return (
<div>
<h1>Email Verification</h1>
<div className="page-center">
{status === "verifying" && (
<div>
<p className="margin-bottom">Verifying your email...</p>
<div className="spinner-verify" />
<style>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
)}
{status === "success" && (
<div>
<p className="verification-status">✓ Successfully verified!</p>
<p>You'll be redirected to your profile page shortly...</p>
</div>
)}
{status === "error" && (
<div>
<p className="verification-status error">Verification failed</p>
<p className="margin-bottom">{error}</p>
{Object.keys(urlParams).length > 0 && (
<div className="debug-panel">
<p className="debug-title">URL Parameters:</p>
{Object.entries(urlParams).map(([key, value]) => (
<div key={key} className="debug-item">
<span className="debug-key">{key}:</span>{" "}
<span className="debug-value">{value}</span>
</div>
))}
</div>
)}
<button
type="button"
onClick={() => navigate("/signin")}
className="auth-button secondary"
>
Back to Sign In
</button>
</div>
)}
</div>
</div>
);
}
```
<Warning>
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
1. Go to your Nhost project dashboard
2. Navigate to **Settings → Authentication**
3. Add your local development URL (e.g., `http://localhost:5173`) to the **Allowed Redirect URLs** field
4. Ensure your production domain is also added when deploying
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
</Warning>
</Step>
<Step>
### Update the App Component to Include New Routes
Configure your application's routing structure to include the new authentication pages. This integrates all the authentication flows into your app's navigation.
```tsx src/App.tsx lines highlight={14-16,35-37}
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Outlet,
Route,
RouterProvider,
} from "react-router-dom";
import Navigation from "./components/Navigation";
import ProtectedRoute from "./components/ProtectedRoute";
import { AuthProvider } from "./lib/nhost/AuthProvider";
import Home from "./pages/Home";
import Profile from "./pages/Profile";
import SignIn from "./pages/SignIn";
import SignUp from "./pages/SignUp";
import Verify from "./pages/Verify";
// Root layout component to wrap all routes
const RootLayout = () => {
return (
<>
<Navigation />
<div className="app-content">
<Outlet />
</div>
</>
);
};
// Create router with routes
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route index element={<Home />} />
<Route path="signin" element={<SignIn />} />
<Route path="signup" element={<SignUp />} />
<Route path="verify" element={<Verify />} />
<Route element={<ProtectedRoute />}>
<Route path="profile" element={<Profile />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Route>,
),
);
function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
export default App;
```
</Step>
<Step>
### Add Navigation Links and Sign Out Functionality
Update the navigation component to include links to the sign-in and sign-up pages, and implement the sign-out.
```tsx src/components/Navigation.tsx lines highlight={1,5-19,38-43,47-52}
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Navigation() {
const { isAuthenticated, session, nhost } = useAuth();
const navigate = useNavigate();
const handleSignOut = async () => {
try {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
navigate("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("Error signing out:", message);
}
};
return (
<nav className="navigation">
<div className="nav-container">
<Link to="/" className="nav-logo">
Nhost React Demo
</Link>
<div className="nav-links">
<Link to="/" className="nav-link">
Home
</Link>
{isAuthenticated ? (
<>
<Link to="/profile" className="nav-link">
Profile
</Link>
<button
type="button"
onClick={handleSignOut}
className="nav-link nav-button"
>
Sign Out
</button>
</>
) : (
<>
<Link to="/signin" className="nav-link">
Sign In
</Link>
<Link to="/signup" className="nav-link">
Sign Up
</Link>
</>
)}
</div>
</div>
</nav>
);
}
```
</Step>
<Step>
### Run and Test the Application
Start your development server and test the complete authentication flow to ensure everything works properly.
```bash
npm run dev
```
Things to try out:
1. Try signing up with a new email address. Check your email for the verification link and click it. See how you are sent to the verification page and then redirected to your profile.
2. Try signing out and then signing back in with the same credentials.
3. Notice how navigation links change based on authentication state showing "Sign In" and "Sign Up" when logged out, and "Profile" and "Sign Out" when logged in.
4. Check how the homepage also reflects the authentication state with appropriate messages.
5. Open multiple tabs and test signing out from one tab to see how other tabs respond. Now sign back in and see the changes propagate across tabs.
</Step>
</Steps>
## Key Features Demonstrated
<AccordionGroup>
<Accordion title="Complete Registration Flow" icon="user-plus">
Full email/password registration with proper form validation and user feedback.
</Accordion>
<Accordion title="Email Verification" icon="envelope-circle-check">
Custom `/verify` endpoint that securely processes email verification tokens.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling for unverified emails, failed authentication, and network issues.
</Accordion>
<Accordion title="Visual Feedback" icon="eye">
Loading states, success messages, and clear error displays throughout the authentication flow.
</Accordion>
<Accordion title="Session Management" icon="clock">
Complete sign out functionality and proper session state management across the application.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,856 @@
---
title: GraphQL Operations in React
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and React
sidebarTitle: "GraphQL Operations"
icon: code
---
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
<Info>
This is **Part 4** in the Full-Stack React Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
</Info>
## Full-Stack React Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
**Current** - CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [User Authentication part](/getting-started/tutorials/react/3-user-authentication) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- **GraphQL queries and mutations** for complete CRUD operations
- **Database schema** with proper relationships and constraints
- **User permissions** for secure data access control
- **React components** that interact with GraphQL endpoint
## Step-by-Step Guide
<Steps>
<Step>
### Create the To-Dos Table
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Click on the SQL Editor
Enter the following SQL:
<Tabs>
<Tab title="SQL">
```sql
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
details text,
completed bool DEFAULT false NOT NULL,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_todos_updated_at
BEFORE UPDATE ON public.todos
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
```
</Tab>
<Tab title="UI">
![Database SQL Editor](/images/tutorials/todos/1.png)
</Tab>
</Tabs>
<Warning>
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
</Warning>
</Step>
<Step>
### Set Up Permissions
Its now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
<Tabs>
<Tab title="Insert">
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
![Insert Permissions Configuration](/images/tutorials/todos/2.png)
</Tab>
<Tab title="Select">
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
![Select Permissions Configuration](/images/tutorials/todos/3.png)
</Tab>
<Tab title="Update">
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
![Update Permissions Configuration](/images/tutorials/todos/4.png)
</Tab>
<Tab title="Delete">
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
![Delete Permissions Configuration](/images/tutorials/todos/5.png)
</Tab>
</Tabs>
</Step>
<Step>
### Create the Todos Page Component
Now let's implement the React component that uses the database we just configured.
```tsx src/pages/Todos.tsx lines
import type { JSX } from "react";
import { useCallback, useEffect, useId, useState } from "react";
import { useAuth } from "../lib/nhost/AuthProvider";
// The interfaces below define the structure of our data
// They are not strictly necessary but help with type safety
// Represents a single todo item
interface Todo {
id: string;
title: string;
details: string | null;
completed: boolean;
created_at: string;
updated_at: string;
user_id: string;
}
// This matches the GraphQL response structure for fetching todos
// Can be used as a generic type on the request method
interface GetTodos {
todos: Todo[];
}
// This matches the GraphQL response structure for inserting a todo
// Can be used as a generic type on the request method
interface InsertTodo {
insert_todos_one: Todo | null;
}
// This matches the GraphQL response structure for updating a todo
// Can be used as a generic type on the request method
interface UpdateTodo {
update_todos_by_pk: Todo | null;
}
export default function Todos(): JSX.Element {
const { nhost, session } = useAuth();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newTodoTitle, setNewTodoTitle] = useState("");
const [newTodoDetails, setNewTodoDetails] = useState("");
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
const titleId = useId();
const detailsId = useId();
const fetchTodos = useCallback(async () => {
try {
setLoading(true);
// Make GraphQL request to fetch todos using Nhost client
// The query automatically filters by user_id due to Hasura permissions
const response = await nhost.graphql.request<GetTodos>({
query: `
query GetTodos {
todos(order_by: { created_at: desc }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
});
// Check for GraphQL errors in the response body
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch todos",
);
}
// Extract todos from the GraphQL response data
setTodos(response.body?.data?.todos || []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch todos");
} finally {
setLoading(false);
}
}, [nhost.graphql]);
const addTodo = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTodoTitle.trim()) return;
try {
// Execute GraphQL mutation to insert a new todo
// user_id is automatically set by Hasura based on JWT token
const response = await nhost.graphql.request<InsertTodo>({
query: `
mutation InsertTodo($title: String!, $details: String) {
insert_todos_one(object: { title: $title, details: $details }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
title: newTodoTitle.trim(),
details: newTodoDetails.trim() || null,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to add todo",
);
}
if (!response.body?.data?.insert_todos_one) {
throw new Error("Failed to add todo");
}
setTodos([response.body?.data?.insert_todos_one, ...todos]);
setNewTodoTitle("");
setNewTodoDetails("");
setShowAddForm(false);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add todo");
}
};
const updateTodo = async (
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
) => {
try {
// Execute GraphQL mutation to update an existing todo by primary key
// Hasura permissions ensure users can only update their own todos
const response = await nhost.graphql.request<UpdateTodo>({
query: `
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
id,
updates,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to update todo",
);
}
if (!response.body?.data?.update_todos_by_pk) {
throw new Error("Failed to update todo");
}
const updatedTodo = response.body?.data?.update_todos_by_pk;
if (updatedTodo) {
setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
}
setEditingTodo(null);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update todo");
}
};
const deleteTodo = async (id: string) => {
if (!confirm("Are you sure you want to delete this todo?")) return;
try {
// Execute GraphQL mutation to delete a todo by primary key
// Hasura permissions ensure users can only delete their own todos
const response = await nhost.graphql.request({
query: `
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
variables: {
id,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to delete todo",
);
}
setTodos(todos.filter((todo) => todo.id !== id));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete todo");
}
};
const toggleComplete = async (todo: Todo) => {
await updateTodo(todo.id, { completed: !todo.completed });
};
const saveEdit = async () => {
if (!editingTodo) return;
await updateTodo(editingTodo.id, {
title: editingTodo.title,
details: editingTodo.details,
});
};
const toggleTodoExpansion = (todoId: string) => {
const newExpanded = new Set(expandedTodos);
if (newExpanded.has(todoId)) {
newExpanded.delete(todoId);
} else {
newExpanded.add(todoId);
}
setExpandedTodos(newExpanded);
};
// Fetch todos when user session is available
// The session contains the JWT token needed for GraphQL authentication
useEffect(() => {
if (session) {
fetchTodos();
}
}, [session, fetchTodos]);
if (!session) {
return (
<div className="auth-message">
<p>Please sign in to view your todos.</p>
</div>
);
}
return (
<div className="container">
<header className="page-header">
<h1 className="page-title">
My Todos
{!showAddForm && (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="add-todo-btn"
title="Add a new todo"
>
+
</button>
)}
</h1>
</header>
{error && (
<div className="error-message">
<strong>Error:</strong> {error}
</div>
)}
{showAddForm && (
<div className="todo-form-card">
<form onSubmit={addTodo} className="todo-form">
<h2 className="form-title">Add New Todo</h2>
<div className="form-fields">
<div className="field-group">
<label htmlFor={titleId}>Title *</label>
<input
id={titleId}
type="text"
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
placeholder="What needs to be done?"
required
/>
</div>
<div className="field-group">
<label htmlFor={detailsId}>Details</label>
<textarea
id={detailsId}
value={newTodoDetails}
onChange={(e) => setNewTodoDetails(e.target.value)}
placeholder="Add some details (optional)..."
rows={3}
/>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary">
Add Todo
</button>
<button
type="button"
onClick={() => {
setShowAddForm(false);
setNewTodoTitle("");
setNewTodoDetails("");
}}
className="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</form>
</div>
)}
{!showAddForm &&
(loading ? (
<div className="loading-container">
<div className="loading-content">
<div className="spinner"></div>
<span className="loading-text">Loading todos...</span>
</div>
</div>
) : (
<div className="todos-list">
{todos.length === 0 ? (
<div className="empty-state">
<svg
className="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 className="empty-title">No todos yet</h3>
<p className="empty-description">
Create your first todo to get started!
</p>
</div>
) : (
todos.map((todo) => (
<div
key={todo.id}
className={`todo-card ${todo.completed ? "completed" : ""}`}
>
{editingTodo?.id === todo.id ? (
<div className="todo-edit">
<div className="edit-fields">
<div className="field-group">
<label htmlFor={`${titleId}-edit`}>Title</label>
<input
id={`${titleId}-edit`}
type="text"
value={editingTodo.title}
onChange={(e) =>
setEditingTodo({
...editingTodo,
title: e.target.value,
})
}
/>
</div>
<div className="field-group">
<label htmlFor={`${detailsId}-edit`}>Details</label>
<textarea
id={`${detailsId}-edit`}
value={editingTodo.details || ""}
onChange={(e) =>
setEditingTodo({
...editingTodo,
details: e.target.value,
})
}
rows={3}
/>
</div>
<div className="edit-actions">
<button
type="button"
onClick={saveEdit}
className="btn btn-primary"
>
✓ Save Changes
</button>
<button
type="button"
onClick={() => setEditingTodo(null)}
className="btn btn-cancel"
>
✕ Cancel
</button>
</div>
</div>
</div>
) : (
<div className="todo-content">
<div className="todo-header">
<button
type="button"
className={`todo-title-btn ${todo.completed ? "completed" : ""}`}
onClick={() => toggleTodoExpansion(todo.id)}
>
{todo.title}
</button>
<div className="todo-actions">
<button
type="button"
onClick={() => toggleComplete(todo)}
className="action-btn action-btn-complete"
title={
todo.completed
? "Mark as incomplete"
: "Mark as complete"
}
>
{todo.completed ? "↶" : "✓"}
</button>
<button
type="button"
onClick={() => setEditingTodo(todo)}
className="action-btn action-btn-edit"
title="Edit todo"
>
✏️
</button>
<button
type="button"
onClick={() => deleteTodo(todo.id)}
className="action-btn action-btn-delete"
title="Delete todo"
>
🗑️
</button>
</div>
</div>
{expandedTodos.has(todo.id) && (
<div className="todo-details">
{todo.details && (
<div
className={`todo-description ${todo.completed ? "completed" : ""}`}
>
<p>{todo.details}</p>
</div>
)}
<div className="todo-meta">
<div className="meta-dates">
<span className="meta-item">
Created:{" "}
{new Date(todo.created_at).toLocaleString()}
</span>
<span className="meta-item">
Updated:{" "}
{new Date(todo.updated_at).toLocaleString()}
</span>
</div>
{todo.completed && (
<div className="completion-badge">
<svg
className="completion-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Completed</span>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
))
)}
</div>
))}
</div>
);
}
```
</Step>
<Step>
### Update App Routes
Add the todos page to your application routing.
```tsx src/App.tsx lines highlight={17,41}
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Outlet,
Route,
RouterProvider,
} from "react-router-dom";
import Navigation from "./components/Navigation";
import ProtectedRoute from "./components/ProtectedRoute";
import { AuthProvider } from "./lib/nhost/AuthProvider";
import Home from "./pages/Home";
import Profile from "./pages/Profile";
import SignIn from "./pages/SignIn";
import SignUp from "./pages/SignUp";
import Todos from "./pages/Todos";
import Verify from "./pages/Verify";
// Root layout component to wrap all routes
const RootLayout = () => {
return (
<>
<Navigation />
<div className="app-content">
<Outlet />
</div>
</>
);
};
// Create router with routes
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route index element={<Home />} />
<Route path="signin" element={<SignIn />} />
<Route path="signup" element={<SignUp />} />
<Route path="verify" element={<Verify />} />
<Route element={<ProtectedRoute />}>
<Route path="profile" element={<Profile />} />
<Route path="todos" element={<Todos />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Route>,
),
);
function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
export default App;
```
</Step>
<Step>
### Update Navigation Links
Add a link to the todos page in the navigation bar.
```tsx src/components/Navigation.tsx lines highlight={35-37}
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Navigation() {
const { isAuthenticated, session, nhost } = useAuth();
const navigate = useNavigate();
const handleSignOut = async () => {
try {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
navigate("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("Error signing out:", message);
}
};
return (
<nav className="navigation">
<div className="nav-container">
<Link to="/" className="nav-logo">
Nhost React Demo
</Link>
<div className="nav-links">
<Link to="/" className="nav-link">
Home
</Link>
{isAuthenticated ? (
<>
<Link to="/todos" className="nav-link">
Todos
</Link>
<Link to="/profile" className="nav-link">
Profile
</Link>
<button
type="button"
onClick={handleSignOut}
className="nav-link nav-button"
>
Sign Out
</button>
</>
) : (
<>
<Link to="/signin" className="nav-link">
Sign In
</Link>
<Link to="/signup" className="nav-link">
Sign Up
</Link>
</>
)}
</div>
</div>
</nav>
);
}
```
</Step>
<Step>
### Test Your Complete Application
Run your application and test all the functionality:
```bash
npm run dev
```
Things to try out:
1. Try signing in and out and see how the Todos page is only available when authenticated
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly
3. Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Database Schema" icon="database">
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
</Accordion>
<Accordion title="GraphQL API" icon="webhook">
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
</Accordion>
<Accordion title="Row-Level Security" icon="shield-check">
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
</Accordion>
<Accordion title="CRUD Operations" icon="arrows-rotate">
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
</Accordion>
<Accordion title="Rich Interface" icon="sparkles">
Expandable todo items, inline editing, completion status, and detailed timestamps.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,715 @@
---
title: File Uploads in React
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and React
sidebarTitle: "File Uploads"
icon: upload
---
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a React application.
<Info>
This is **Part 5** in the Full-Stack React Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
</Info>
## Full-Stack React Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/react/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/react/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/react/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/react/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/react/5-file-uploads">
**Current** - File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [GraphQL Operations part](/getting-started/tutorials/react/4-graphql-operations) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- A **personal bucket** so users can upload their own private files
- **File upload functionality**
- **File management interface** for viewing and deleting files
- **Security permissions** ensuring users can only access their own files
## Step-by-Step Guide
<Steps>
<Step>
### Create a Personal Storage Bucket
First, we'll create a storage bucket where users can upload their personal files.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Change to **schema.storage**, then buckets
3. Now click on `+ Insert` on the top right corner.
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
![Create bucket](/images/tutorials/uploads/1.png)
</Step>
<Step>
### Configure Storage Permissions
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
<Tabs>
<Tab title="Upload">
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
![upload files permissions](/images/tutorials/uploads/2.png)
</Tab>
<Tab title="Download">
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
![download files permissions](/images/tutorials/uploads/3.png)
</Tab>
<Tab title="Delete">
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
![delete files permissions](/images/tutorials/uploads/4.png)
</Tab>
</Tabs>
<Info>
You can read more about storage permissions [here](/products/storage/overview#permissions)
</Info>
</Step>
<Step>
### Create the File Upload Component
Now let's implement the React component for file upload functionality.
```tsx src/pages/Files.tsx lines
import type { FileMetadata } from "@nhost/nhost-js/storage";
import { type JSX, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "../lib/nhost/AuthProvider";
interface DeleteStatus {
message: string;
isError: boolean;
}
interface GraphqlGetFilesResponse {
files: FileMetadata[];
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
}
export default function Files(): JSX.Element {
const { isAuthenticated, nhost } = useAuth();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [uploading, setUploading] = useState<boolean>(false);
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
const [isFetching, setIsFetching] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileMetadata[]>([]);
const [viewingFile, setViewingFile] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
const fetchFiles = useCallback(async (): Promise<void> => {
setIsFetching(true);
setError(null);
try {
// Use GraphQL to fetch files from the storage system
// Files are automatically filtered by user permissions
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
query: `query GetFiles {
files {
id
name
size
mimeType
bucketId
uploadedByUserId
}
}`,
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch files",
);
}
setFiles(response.body.data?.files || []);
} catch (err) {
console.error("Error fetching files:", err);
setError("Failed to load files. Please try refreshing the page.");
} finally {
setIsFetching(false);
}
}, [nhost.graphql]);
useEffect(() => {
if (isAuthenticated) {
fetchFiles();
}
}, [isAuthenticated, fetchFiles]);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
if (file) {
setSelectedFile(file);
setError(null);
setUploadResult(null);
}
}
};
const handleUpload = async (): Promise<void> => {
if (!selectedFile) {
setError("Please select a file to upload");
return;
}
setUploading(true);
setError(null);
try {
// Upload file to the personal bucket
// The uploadedByUserId is automatically set by the storage permissions
const response = await nhost.storage.uploadFiles({
"bucket-id": "personal",
"file[]": [selectedFile],
});
const uploadedFile = response.body.processedFiles?.[0];
if (uploadedFile === undefined) {
throw new Error("Failed to upload file");
}
setUploadResult(uploadedFile);
// Clear the form
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
// Update the files list
setFiles((prevFiles) => [uploadedFile, ...prevFiles]);
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
setUploadResult(null);
}, 3000);
} catch (err: unknown) {
const message = (err as Error).message || "An unknown error occurred";
setError(`Failed to upload file: ${message}`);
} finally {
setUploading(false);
}
};
const handleViewFile = async (
fileId: string,
fileName: string,
mimeType: string,
): Promise<void> => {
setViewingFile(fileId);
try {
// Get the file from storage
const response = await nhost.storage.getFile(fileId);
const url = URL.createObjectURL(response.body);
// Handle different file types appropriately
if (
mimeType.startsWith("image/") ||
mimeType === "application/pdf" ||
mimeType.startsWith("text/") ||
mimeType.startsWith("video/") ||
mimeType.startsWith("audio/")
) {
// Open viewable files in new tab
window.open(url, "_blank");
} else {
// Download other file types
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Show download confirmation
const newWindow = window.open("", "_blank", "width=400,height=200");
if (newWindow) {
newWindow.document.documentElement.innerHTML = `
<head>
<title>File Download</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
</style>
</head>
<body>
<h3>Downloading: ${fileName}</h3>
<p>Your download has started. You can close this window.</p>
</body>
`;
}
}
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
setError(`Failed to view file: ${message}`);
console.error("Error viewing file:", err);
} finally {
setViewingFile(null);
}
};
const handleDeleteFile = async (fileId: string): Promise<void> => {
if (!fileId || deleting) return;
setDeleting(fileId);
setError(null);
setDeleteStatus(null);
const fileToDelete = files.find((file) => file.id === fileId);
const fileName = fileToDelete?.name || "File";
try {
// Delete file from storage
// Permissions ensure users can only delete their own files
await nhost.storage.deleteFile(fileId);
setDeleteStatus({
message: `${fileName} deleted successfully`,
isError: false,
});
// Remove from local state
setFiles(files.filter((file) => file.id !== fileId));
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
setDeleteStatus(null);
}, 3000);
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
setDeleteStatus({
message: `Failed to delete ${fileName}: ${message}`,
isError: true,
});
console.error("Error deleting file:", err);
} finally {
setDeleting(null);
}
};
return (
<div className="container">
<header className="page-header">
<h1 className="page-title">File Upload</h1>
</header>
<div className="form-card">
<h2 className="form-title">Upload a File</h2>
<div className="field-group">
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
style={{
position: "absolute",
width: "1px",
height: "1px",
padding: 0,
margin: "-1px",
overflow: "hidden",
clip: "rect(0,0,0,0)",
border: 0,
}}
aria-hidden="true"
tabIndex={-1}
/>
<button
type="button"
className="btn btn-secondary file-upload-btn"
onClick={() => fileInputRef.current?.click()}
>
<svg
width="40"
height="40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="Upload file"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p>Click to select a file</p>
{selectedFile && (
<p className="file-upload-info">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
)}
</button>
</div>
{error && <div className="error-message">{error}</div>}
{uploadResult && (
<div className="success-message">File uploaded successfully!</div>
)}
<button
type="button"
onClick={handleUpload}
disabled={!selectedFile || uploading}
className="btn btn-primary"
style={{ width: "100%" }}
>
{uploading ? "Uploading..." : "Upload File"}
</button>
</div>
<div className="form-card">
<h2 className="form-title">Your Files</h2>
{deleteStatus && (
<div
className={
deleteStatus.isError ? "error-message" : "success-message"
}
>
{deleteStatus.message}
</div>
)}
{isFetching ? (
<div className="loading-container">
<div className="loading-content">
<div className="spinner"></div>
<span className="loading-text">Loading files...</span>
</div>
</div>
) : files.length === 0 ? (
<div className="empty-state">
<svg
className="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<h3 className="empty-title">No files yet</h3>
<p className="empty-description">
Upload your first file to get started!
</p>
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table className="file-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id}>
<td className="file-name">{file.name}</td>
<td className="file-meta">{file.mimeType}</td>
<td className="file-meta">
{formatFileSize(file.size || 0)}
</td>
<td>
<div className="file-actions">
<button
type="button"
onClick={() =>
handleViewFile(
file.id || "unknown",
file.name || "unknown",
file.mimeType || "unknown",
)
}
disabled={viewingFile === file.id}
className="action-btn action-btn-edit"
title="View File"
>
{viewingFile === file.id ? "⏳" : "👁️"}
</button>
<button
type="button"
onClick={() => handleDeleteFile(file.id || "unknown")}
disabled={deleting === file.id}
className="action-btn action-btn-delete"
title="Delete File"
>
{deleting === file.id ? "⏳" : "🗑️"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
```
</Step>
<Step>
### Update Application Routes
Add the uploads page to your application routing.
```tsx src/App.tsx lines highlight={18,43}
import {
createBrowserRouter,
createRoutesFromElements,
Navigate,
Outlet,
Route,
RouterProvider,
} from "react-router-dom";
import Navigation from "./components/Navigation";
import ProtectedRoute from "./components/ProtectedRoute";
import { AuthProvider } from "./lib/nhost/AuthProvider";
import Files from "./pages/Files";
import Home from "./pages/Home";
import Profile from "./pages/Profile";
import SignIn from "./pages/SignIn";
import SignUp from "./pages/SignUp";
import Todos from "./pages/Todos";
import Verify from "./pages/Verify";
// Root layout component to wrap all routes
const RootLayout = () => {
return (
<>
<Navigation />
<div className="app-content">
<Outlet />
</div>
</>
);
};
// Create router with routes
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<RootLayout />}>
<Route index element={<Home />} />
<Route path="signin" element={<SignIn />} />
<Route path="signup" element={<SignUp />} />
<Route path="verify" element={<Verify />} />
<Route element={<ProtectedRoute />}>
<Route path="profile" element={<Profile />} />
<Route path="todos" element={<Todos />} />
<Route path="files" element={<Files />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />
</Route>,
),
);
function App() {
return (
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
);
}
export default App;
```
</Step>
<Step>
### Update Navigation Links
Add a link to the uploads page in the navigation bar.
```tsx src/components/Navigation.tsx lines highlight={38-40}
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../lib/nhost/AuthProvider";
export default function Navigation() {
const { isAuthenticated, session, nhost } = useAuth();
const navigate = useNavigate();
const handleSignOut = async () => {
try {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
navigate("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("Error signing out:", message);
}
};
return (
<nav className="navigation">
<div className="nav-container">
<Link to="/" className="nav-logo">
Nhost React Demo
</Link>
<div className="nav-links">
<Link to="/" className="nav-link">
Home
</Link>
{isAuthenticated ? (
<>
<Link to="/todos" className="nav-link">
Todos
</Link>
<Link to="/files" className="nav-link">
Files
</Link>
<Link to="/profile" className="nav-link">
Profile
</Link>
<button
type="button"
onClick={handleSignOut}
className="nav-link nav-button"
>
Sign Out
</button>
</>
) : (
<>
<Link to="/signin" className="nav-link">
Sign In
</Link>
<Link to="/signup" className="nav-link">
Sign Up
</Link>
</>
)}
</div>
</div>
</nav>
);
}
```
</Step>
<Step>
### Test Your File Upload System
Run your application and test all the functionality:
```bash
npm run dev
```
Things to try out:
1. Try signing in and out and see how the file upload page is only accessible when signed in.
2. Upload different types of files (images, documents, etc.)
3. View and delete files
4. Sign in with another account and verify you cannot see files from the first account
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Storage Bucket" icon="bucket">
Dedicated personal storage bucket with proper configuration for user file isolation.
</Accordion>
<Accordion title="File Upload Interface" icon="upload">
User-friendly upload interface with file selection, preview, and progress feedback.
</Accordion>
<Accordion title="File Management" icon="folder">
Complete file listing with metadata, viewing capabilities, and deletion functionality.
</Accordion>
<Accordion title="File Type Handling" icon="file">
Intelligent handling of different file types with appropriate viewing/download behavior.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling with user-friendly messages for upload and management operations.
</Accordion>
</AccordionGroup>

View File

@@ -1,443 +0,0 @@
---
title: Get up and running with Nhost and React Native
sidebarTitle: React Native
description: In this quickstart guide, we'll demonstrate how to build a simple To-Do feature using Nhost and React Native.
icon: mobile-notch
---
<Card>
Throughout this guide, we'll utilize the **@nhost/react-native-template**, which comes pre-configured with
authentication and storage capabilities provided by Nhost.
</Card>
<br />
<Note>
Before starting this quickstart, ensure that your environment is set up to work with React Native.
Follow the [setup guide](https://reactnative.dev/docs/next/set-up-your-environment) available on
the official React Native website.
</Note>
<Steps>
<Step title="Create Nhost Project">
Create your project through the [Nhost Dashboard](https://app.nhost.io).
</Step>
<Step title="Setup Database">
Navigate to the **SQL Editor** of the database and run the following SQL to create a new table `todos`.
<Warning>Make sure the option `Track this` is enabled</Warning>
```sql SQL Editor
CREATE TABLE todos (
id uuid NOT NULL DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
user_id uuid NOT NULL,
contents text NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE cascade ON DELETE cascade
);
```
![Create Todos Table](/images/quickstarts/react-native/create-table-todos.png)
</Step>
<Step title="Configure the todos table permissions">
To set permissions for the new `todos` table, select the table, click on the `...` to open the actions dialog,
then click on **Edit Permissions**. Set the following permissions for the `user` role:
1. `Insert`
- Set `Row insert permissions` to `Without any checks`
- Select all columns except `user_id` on `Column insert permissions`
- Add a new `Column preset` and set `Column Name` to `user_id` and `Column Value` to `X-Hasura-User-Id`
- Save
![Insert Permissions](/images/quickstarts/react-native/todos-insert-permissions.png)
2. `Select`
- Set `Row select permissions` to `With custom check` and fill in the following rule:
- Set `Where` to `todos.user_id`
- Set the operator to `_eq`
- Set the value to `X-Hasura-User-Id`
- Select all columns except `user_id` on `Column select permissions`
- Save
![Select Permissions](/images/quickstarts/react-native/todos-select-permissions.png)
3. `Update`
- Set `Row update permissions` to `With custom check` and fill in the following rule:
- Set `Where` to `todos.user_id`
- Set the operator to `_eq`
- Set the value to `X-Hasura-User-Id`
- Select all columns except `user_id` on `Column select permissions`
- Save
![Update permissions](/images/quickstarts/react-native/todos-update-permissions.png)
4. `Delete`
- Set `Row delete permissions` to `With custom check` and fill in the following rule:
- Set `Where` to `todos.user_id`
- Set the operator to `_eq`
- Set the value to `X-Hasura-User-Id`
- Save
![Delete permissions](/images/quickstarts/react-native/todos-delete-permissions.png)
</Step>
<Step title="Configure permissions to enable user file uploads">
To enable file uploads by users, set the permissions as follows:
1. Edit the **files** table permissions
1. Navigate to the files table within the [Database tab](https://app.nhost.io/_/_/database/browser/default/storage/files)
2. Click on the three dots (...) next to the files table
3. Click on **Edit Permissions**
2. Modify the `Insert` permission for the `user` role:
1. Set `Row insert permissions` to `Without any checks`
2. Select all columns on `Column insert permissions`
4. Save
![Insert Permissions](/images/quickstarts/react-native/files-insert-permissions.png)
3. `Select`
- Set `Row select permissions` to `With custom check` and fill in the following rule:
- Set `Where` to `files.uploaded_by_user_id`
- Set the operator to `_eq`
- Set the value to `X-Hasura-User-Id`
- Select all columns on `Column select permissions`
- Save
![Select permissions](/images/quickstarts/react-native/files-select-permissions.png)
</Step>
<Step title="Bootstrap your React Native app">
Intialize a new React Native project using the template `@nhost/react-native-template`
```bash Terminal
npx react-native init myapp --template @nhost/react-native-template
```
</Step>
<Step title="Connect your React Native app to the Nhost project">
Copy your project's `<subdomain>` and `<region>` values available on the dashboard overview
```tsx src/root.tsx
const nhost = new NhostClient({
subdomain: "<subdomain>", // replace the subdomain value e.g. "hjcuuqweqwezolpolrep"
region: "<region>", // replace the region value e.g. "eu-central-1"
clientStorageType: 'react-native',
clientStorage: AsyncStorage,
});
```
</Step>
<Step title="Add the GraphQL queries">
Create a new file `src/graphql/todos.ts` that will expose the graphql queries needed to `list`, `add` and `delete` To-Do's.
```ts src/graphql/todos.ts
import {gql} from '@apollo/client';
export const GET_TODOS = gql`
query listTodos {
todos(order_by: { created_at: desc }) {
id
contents
}
}
`;
export const ADD_TODO = gql`
mutation addTodo($contents: String!) {
insert_todos_one(object: { contents: $contents }) {
id
contents
}
}
`;
export const DELETE_TODO = gql`
mutation deleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
__typename
}
}
`;
```
</Step>
<Step title="Add a form to insert a To-Do">
```tsx src/components/AddTodoForm.tsx
import React from 'react';
import {useMutation} from '@apollo/client';
import Button from '@components/Button';
import ControlledInput from '@components/ControlledInput';
import {ADD_TODO, GET_TODOS} from '@graphql/todos';
import {useForm} from 'react-hook-form';
import {StyleSheet, View} from 'react-native';
interface AddTodoFormValues {
contents: string;
}
export default function AddTodoForm() {
const {control, handleSubmit, reset} = useForm<AddTodoFormValues>();
const [addTodo, {loading}] = useMutation(ADD_TODO, {
refetchQueries: [{query: GET_TODOS}],
});
const onSubmit = async (values: AddTodoFormValues) => {
const {contents} = values;
await addTodo({variables: {contents}});
reset();
};
return (
<View style={styles.wrapper}>
<View style={styles.inputWrapper}>
<ControlledInput
control={control}
name="contents"
placeholder="New To-Do"
autoCapitalize="none"
rules={{
required: true,
}}
/>
</View>
<View style={styles.buttonWrapper}>
<Button
label="Add"
onPress={handleSubmit(onSubmit)}
disabled={loading}
loading={loading}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
gap: 12,
padding: 12,
flexDirection: 'row',
backgroundColor: 'white',
},
inputWrapper: {
flex: 3,
},
buttonWrapper: {
flex: 1,
},
});
```
</Step>
<Step title="Add the Todo component and the screen to list all the todos">
<CodeGroup>
```tsx src/components/Todo.tsx
import React from 'react';
import {useMutation} from '@apollo/client';
import {DELETE_TODO, GET_TODOS} from '@graphql/todos';
import {StyleSheet, Text, View} from 'react-native';
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
import Button from './Button';
export interface TodoItem {
id: string;
contents: string;
}
export default function Todo({todo: {id, contents}}: {todo: TodoItem}) {
const [deleteTodo] = useMutation(DELETE_TODO, {
variables: {id},
refetchQueries: [{query: GET_TODOS}],
});
const handleDeleteTodo = async () => {
await deleteTodo();
};
return (
<View style={styles.wrapper}>
<View style={styles.todoContentWrapper}>
<Icon name="check" size={25} />
<Text style={styles.todoContent}>{contents}</Text>
</View>
<View style={styles.buttonWrapper}>
<Button
label={<Icon name="trash-can-outline" size={20} />}
color="#f1f1f1"
onPress={handleDeleteTodo}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
wrapper: {
padding: 14,
flexDirection: 'row',
alignItems: 'center',
},
todoContentWrapper: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: 20,
},
todoContent: {flex: 1},
buttonWrapper: {
width: 50,
},
});
```
```tsx src/screens/Todos.tsx
import React from 'react';
import {useQuery} from '@apollo/client';
import AddTodoForm from '@components/AddTodoForm';
import Todo, {type TodoItem} from '@components/Todo';
import {GET_TODOS} from '@graphql/todos';
import {useEffect} from 'react';
import {ActivityIndicator, FlatList, StyleSheet, View} from 'react-native';
export default function Todos() {
const {loading, data, client} = useQuery<{todos: TodoItem[]}>(GET_TODOS);
const todos = data?.todos || [];
useEffect(() => {
return () => client.stop();
}, [client]);
if (loading) {
return (
<View style={styles.loadingViewWrapper}>
<ActivityIndicator />
</View>
);
}
const renderTodo = ({item}: {item: TodoItem}) => <Todo todo={item} />;
const itemSeperator = () => <View style={styles.separator} />;
return (
<View style={styles.wrapper}>
<AddTodoForm />
<FlatList
data={todos}
keyExtractor={item => item.id}
renderItem={renderTodo}
ItemSeparatorComponent={itemSeperator}
/>
</View>
);
}
const styles = StyleSheet.create({
loadingViewWrapper: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
wrapper: {
flex: 1,
backgroundColor: 'white',
},
separator: {
height: 1,
backgroundColor: '#f1f1f1',
},
});
```
</CodeGroup>
</Step>
<Step title="Reference the new Todos components in the Drawer Navigator">
```tsx src/screens/Main.tsx
function DrawerNavigator() {
return (
<Drawer.Navigator
screenOptions={screenOptions}
drawerContent={drawerContent}>
<Drawer.Screen name="Profile" component={Profile} />
{/* Add the Todos component here */}
<Drawer.Screen name="Todos" component={Todos} />
<Drawer.Screen name="Storage" component={Storage} />
</Drawer.Navigator>
);
}
```
</Step>
<Step title="Run the app on the emulator">
<Tabs>
<Tab title="Android">
1. Open a terminal and start the metro bundler
```bash Terminal
cd myapp
npm start
```
2. Open a new terminal and run the app on Android
```bash Terminal
cd myapp
npm run android
```
</Tab>
<Tab title="iOS">
1. Make sure the iOS project cocopods are installed
```bash Terminal
cd ios
pod install
```
1. Install the `ios-deploy` CLI
```bash Terminal
npm install -g ios-deploy
```
2. Start the metro bundler
```bash Terminal
cd myapp
npm start
```
3. Open a new terminal and run the app on Android
```bash Terminal
cd myapp
npm run ios --interactive
```
</Tab>
</Tabs>
</Step>
<Step title="Demo">
<iframe
width="486"
height="864"
src="https://www.youtube.com/embed/gfzksbce2G4"
title="demo react native"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
/>
</Step>
</Steps>
<Note>
### Next Steps: enabling Google and Apple Sign-In
The template is preconfigured to allow users to sign in with Google and Apple. To enable this feature, follow these steps:
1. Navigate to your Nhost project's [Sign-In Methods settings](https://app.nhost.io/_/_/settings/sign-in-methods).
2. Enable Google and/or Apple sign-in.
3. Fill in the necessary credentials.
For detailed instructions on generating the required credentials, refer to the following guides:
- [Google Sign-In Guide](https://docs.nhost.io/products/auth/social/sign-in-google)
- [Apple Sign-In Guide](https://docs.nhost.io/products/auth/social/sign-in-apple)
</Note>

View File

@@ -0,0 +1,121 @@
---
title: Create Your Nhost Project
description: Learn how to create and set up a new Nhost project to get started building your React Native application
sidebarTitle: Create Project
icon: plus
---
Welcome to the **Full-Stack React Native Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete React Native application with Nhost that demonstrates authentication, database operations, and file management.
## About This Tutorial Series
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern mobile applications with Nhost and React Native. By the end of the series, you'll have built a fully functional application featuring:
- **User Authentication** - Complete sign up, sign in, and email verification flow
- **Todo Management** - Users can create, update, delete, and mark todos as complete
- **File Uploads** - Users can upload and manage files with proper permissions
- **Protected Routes** - Secure screens that only authenticated users can access
<Info>
This is **Part 1** in the Full-Stack React Native Development with Nhost series. This part sets up the foundation by creating your Nhost project and understanding the series structure.
</Info>
## Full-Stack React Native Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
**Current** - Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
File upload and management
</Card>
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
Apple authentication integration
</Card>
</CardGroup>
## What You'll Learn
Throughout this series, you'll master:
- Setting up and configuring Nhost projects
- Implementing secure authentication flows with React Native
- Building protected screens with Expo Router
- Performing GraphQL queries and mutations
- Managing file uploads and storage
- Configuring database permissions and security
- Building responsive React Native interfaces
## Prerequisites
- Node.js 20+ installed on your machine
- Basic knowledge of React Native and JavaScript
- Understanding of modern mobile development concepts
- Expo CLI installed globally (`npm install -g @expo/cli`)
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
## Step-by-Step Guide
<Steps>
<Step>
### Sign Up or Log in
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
![sign up/sign in](/images/tutorials/create-nhost-project/1.png)
</Step>
<Step>
### Create a New Project
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
![2](/images/tutorials/create-nhost-project/2.png)
</Step>
<Step>
### Take note of your project subdomain and region
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
![3](/images/tutorials/create-nhost-project/3.png)
</Step>
</Steps>
## What's Next?
With your Nhost project created, you now have access to:
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
- [**File Storage**](/products/storage/overview) - For uploading and managing files
- [**Functions**](/products/functions/overview) - For running serverless functions
In the [next tutorial](/getting-started/tutorials/reactnative/2-protected-routes), you'll start building your React Native application and learn how to protect screens based on user authentication status.
<Tip>
Keep your project subdomain and region handy - you'll need them throughout the series to connect your React Native application to the Nhost backend.
</Tip>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,805 @@
---
title: User Authentication in React Native
description: Learn how to implement user authentication in a React Native application using Nhost
sidebarTitle: "User Authentication"
icon: user
---
This tutorial part builds upon the [Protected Screens part](/getting-started/tutorials/reactnative/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
<Info>
This is **Part 3** in the Full-Stack React Native Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling.
</Info>
## Full-Stack React Native Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Screens" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
Screen protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
**Current** - Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
File upload and management
</Card>
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
Apple authentication integration
</Card>
</CardGroup>
## Prerequisites
- Complete the [Protected Screens part](/getting-started/tutorials/reactnative/2-protected-routes) first
- The project from the previous part set up and running
## Step-by-Step Guide
<Steps>
<Step>
### Create the Sign In Screen
Build a comprehensive sign-in form with proper error handling and loading states. This screen handles user authentication and includes special logic for post-verification sign-in.
```tsx app/signin.tsx lines
import { Link, router } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";
export default function SignIn() {
const { nhost, isAuthenticated } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Use useEffect for navigation after authentication is confirmed
useEffect(() => {
if (isAuthenticated) {
router.replace("/profile");
}
}, [isAuthenticated]);
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
// Use the signIn function from auth context
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
// If we have a session, sign in was successful
if (response.body?.session) {
router.replace("/profile");
} else {
setError("Failed to sign in. Please check your credentials.");
}
} catch (err) {
const message = (err as Error).message || "Unknown error";
setError(`An error occurred during sign in: ${message}`);
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={commonStyles.container}
>
<ScrollView
contentContainerStyle={commonStyles.centerContent}
keyboardShouldPersistTaps="handled"
>
<Text style={commonStyles.title}>Sign In</Text>
<View style={commonStyles.card}>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Email</Text>
<TextInput
style={commonStyles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Password</Text>
<TextInput
style={commonStyles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
/>
</View>
{error && (
<View style={commonStyles.errorContainer}>
<Text style={commonStyles.errorText}>{error}</Text>
</View>
)}
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color={colors.surface} />
) : (
<Text style={commonStyles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
<View style={commonStyles.linkContainer}>
<Text style={commonStyles.linkText}>
Don't have an account?{" "}
<Link href="/signup" style={commonStyles.link}>
Sign Up
</Link>
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
```
</Step>
<Step>
### Create the Sign Up Screen
Implement user registration with email verification flow. This screen collects user information, creates accounts, and guides users through the email verification process.
```tsx app/signup.tsx lines
import * as Linking from "expo-linking";
import { Link, router } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";
export default function SignUp() {
const { nhost, isAuthenticated } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Redirect authenticated users to profile
useEffect(() => {
if (isAuthenticated) {
router.replace("/profile");
}
}, [isAuthenticated]);
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
setSuccess(false);
try {
const response = await nhost.auth.signUpEmailPassword({
email,
password,
options: {
displayName,
// Set the redirect URL for email verification
redirectTo: Linking.createURL("verify"),
},
});
if (response.body?.session) {
// Successfully signed up and automatically signed in
router.replace("/profile");
} else {
// Verification email sent
setSuccess(true);
}
} catch (err) {
const message = (err as Error).message || "Unknown error";
setError(`An error occurred during sign up: ${message}`);
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<View style={commonStyles.centerContent}>
<Text style={commonStyles.title}>Check Your Email</Text>
<View style={commonStyles.successContainer}>
<Text style={commonStyles.successText}>
We've sent a verification link to{" "}
<Text style={commonStyles.emailText}>{email}</Text>
</Text>
<Text style={[commonStyles.bodyText, commonStyles.textCenter]}>
Please check your email and click the verification link to activate
your account.
</Text>
</View>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.replace("/signin")}
>
<Text style={commonStyles.buttonText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={commonStyles.container}
>
<ScrollView
contentContainerStyle={commonStyles.centerContent}
keyboardShouldPersistTaps="handled"
>
<Text style={commonStyles.title}>Sign Up</Text>
<View style={commonStyles.card}>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Display Name</Text>
<TextInput
style={commonStyles.input}
value={displayName}
onChangeText={setDisplayName}
placeholder="Enter your name"
autoCapitalize="words"
/>
</View>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Email</Text>
<TextInput
style={commonStyles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Password</Text>
<TextInput
style={commonStyles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
/>
<Text style={commonStyles.helperText}>Minimum 8 characters</Text>
</View>
{error && (
<View style={commonStyles.errorContainer}>
<Text style={commonStyles.errorText}>{error}</Text>
</View>
)}
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color={colors.surface} />
) : (
<Text style={commonStyles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</View>
<View style={commonStyles.linkContainer}>
<Text style={commonStyles.linkText}>
Already have an account?{" "}
<Link href="/signin" style={commonStyles.link}>
Sign In
</Link>
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
```
</Step>
<Step>
### Create the Email Verification Screen
Build a dedicated verification screen that processes email verification tokens. This screen handles the verification flow when users click the email verification link.
```tsx app/verify.tsx lines
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";
export default function Verify() {
const params = useLocalSearchParams();
const [status, setStatus] = useState<"verifying" | "success" | "error">(
"verifying",
);
const [error, setError] = useState<string | null>(null);
const [urlParams, setUrlParams] = useState<Record<string, string>>({});
const { nhost } = useAuth();
useEffect(() => {
// Extract the refresh token from the URL
const refreshToken = params.refreshToken as string;
if (!refreshToken) {
// Collect all URL parameters to display for debugging
const allParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
if (typeof value === "string") {
allParams[key] = value;
}
});
setUrlParams(allParams);
setStatus("error");
setError("No refresh token found in URL");
return;
}
// Flag to handle component unmounting during async operations
let isMounted = true;
async function processToken(): Promise<void> {
try {
// First display the verifying message for at least a moment
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isMounted) return;
if (!refreshToken) {
// Collect all URL parameters to display
const allParams: Record<string, string> = {};
Object.entries(params).forEach(([key, value]) => {
if (typeof value === "string") {
allParams[key] = value;
}
});
setUrlParams(allParams);
setStatus("error");
setError("No refresh token found in URL");
return;
}
// Process the token
await nhost.auth.refreshToken({ refreshToken });
if (!isMounted) return;
setStatus("success");
// Wait to show success message briefly, then redirect
setTimeout(() => {
if (isMounted) router.replace("/profile");
}, 1500);
} catch (err) {
const message = (err as Error).message || "Unknown error";
if (!isMounted) return;
setStatus("error");
setError(`An error occurred during verification: ${message}`);
}
}
processToken();
// Cleanup function
return () => {
isMounted = false;
};
}, [params, nhost.auth]);
return (
<View style={commonStyles.centerContent}>
<Text style={commonStyles.title}>Email Verification</Text>
<View style={commonStyles.card}>
{status === "verifying" && (
<View style={commonStyles.alignCenter}>
<Text style={[commonStyles.bodyText, commonStyles.marginBottom]}>
Verifying your email...
</Text>
<ActivityIndicator size="large" color={colors.primary} />
</View>
)}
{status === "success" && (
<View style={commonStyles.alignCenter}>
<Text style={commonStyles.successText}>
✓ Successfully verified!
</Text>
<Text style={commonStyles.bodyText}>
You'll be redirected to your profile page shortly...
</Text>
</View>
)}
{status === "error" && (
<View style={commonStyles.alignCenter}>
<Text style={commonStyles.errorText}>Verification failed</Text>
<Text style={[commonStyles.bodyText, commonStyles.marginBottom]}>
{error}
</Text>
{Object.keys(urlParams).length > 0 && (
<View style={commonStyles.debugContainer}>
<Text style={commonStyles.debugTitle}>URL Parameters:</Text>
{Object.entries(urlParams).map(([key, value]) => (
<View key={key} style={commonStyles.debugItem}>
<Text style={commonStyles.debugKey}>{key}:</Text>
<Text style={commonStyles.debugValue}>{value}</Text>
</View>
))}
</View>
)}
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.replace("/signin")}
>
<Text style={commonStyles.buttonText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
)}
</View>
</View>
);
}
```
<Warning>
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
1. Go to your Nhost project dashboard
2. Navigate to **Settings → Authentication**
3. Add your Expo development URL to the **Allowed Redirect URLs** field:
- For Expo Go: `exp://x.x.x.x:8081/` (replace with your local IP)
4. Ensure your production domain is also added when deploying
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
</Warning>
</Step>
<Step>
### Update Home Screen
Update the home screen to include navigation to authentication screens and a button to sign out.
```tsx app/index.tsx lines highlight={2, 8-31, 61-78}
import { useRouter } from "expo-router";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, homeStyles } from "./styles/commonStyles";
export default function Index() {
const router = useRouter();
const { isAuthenticated, session, nhost, user } = useAuth();
const handleSignOut = async () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
router.replace("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
Alert.alert("Error", `Failed to sign out: ${message}`);
}
},
},
]);
};
return (
<View style={commonStyles.centerContent}>
<Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>
<View style={homeStyles.welcomeCard}>
{isAuthenticated ? (
<View style={{ gap: 15, width: "100%" }}>
<Text style={homeStyles.welcomeText}>
Hello, {user?.displayName || user?.email}!
</Text>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/profile")}
>
<Text style={commonStyles.buttonText}>Go to Profile</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
onPress={handleSignOut}
>
<Text style={commonStyles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</View>
) : (
<>
<Text style={homeStyles.authMessage}>You are not signed in.</Text>
<View style={{ gap: 15, width: "100%" }}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/signin")}
>
<Text style={commonStyles.buttonText}>Sign In</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
commonStyles.button,
commonStyles.buttonSecondary,
commonStyles.fullWidth,
]}
onPress={() => router.push("/signup")}
>
<Text style={commonStyles.buttonText}>Sign Up</Text>
</TouchableOpacity>
</View>
</>
)}
</View>
</View>
);
}
```
</Step>
<Step title="Add Sign Out Functionality">
Update the profile screen to include sign-out functionality:
```tsx app/profile.tsx lines highlight={1-2,8-36,103-108}
import { useRouter } from "expo-router";
import { Alert, ScrollView, Text, TouchableOpacity, View } from "react-native";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, profileStyles } from "./styles/commonStyles";
export default function Profile() {
const router = useRouter();
const { user, session, nhost } = useAuth();
const handleSignOut = async () => {
Alert.alert(
"Sign Out",
"Are you sure you want to sign out?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
router.replace("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
Alert.alert("Error", `Failed to sign out: ${message}`);
}
},
},
]
);
};
return (
<ProtectedScreen>
<ScrollView
style={commonStyles.container}
contentContainerStyle={commonStyles.contentContainer}
>
<Text style={commonStyles.title}>Your Profile</Text>
<View style={commonStyles.card}>
<Text style={commonStyles.cardTitle}>User Information</Text>
<View style={profileStyles.profileItem}>
<Text style={commonStyles.labelText}>Display Name:</Text>
<Text style={commonStyles.valueText}>
{user?.displayName || "Not set"}
</Text>
</View>
<View style={profileStyles.profileItem}>
<Text style={commonStyles.labelText}>Email:</Text>
<Text style={commonStyles.valueText}>
{user?.email || "Not available"}
</Text>
</View>
<View style={profileStyles.profileItem}>
<Text style={commonStyles.labelText}>User ID:</Text>
<Text
style={commonStyles.valueText}
numberOfLines={1}
ellipsizeMode="middle"
>
{user?.id || "Not available"}
</Text>
</View>
<View style={profileStyles.profileItem}>
<Text style={commonStyles.labelText}>Roles:</Text>
<Text style={commonStyles.valueText}>
{user?.roles?.join(", ") || "None"}
</Text>
</View>
<View style={[profileStyles.profileItem, profileStyles.profileItemLast]}>
<Text style={commonStyles.labelText}>Email Verified:</Text>
<Text style={[
commonStyles.valueText,
user?.emailVerified ? commonStyles.successText : commonStyles.errorText
]}>
{user?.emailVerified ? "✓ Yes" : "✗ No"}
</Text>
</View>
</View>
<View style={commonStyles.card}>
<Text style={commonStyles.cardTitle}>Session Information</Text>
<View style={commonStyles.sessionInfo}>
<Text
style={commonStyles.sessionValue}
>
{JSON.stringify(session, null, 2)}
</Text>
</View>
</View>
<TouchableOpacity
style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
onPress={handleSignOut}
>
<Text style={commonStyles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</ScrollView>
</ProtectedScreen>
);
}
```
</Step>
<Step>
### Run and Test the Application
Start your development server and test the complete authentication flow to ensure everything works properly.
```bash
npm run start
```
Things to try out:
1. **Sign Up Flow**: Try signing up with a new email address. Check your email for the verification link and click it. See how you are sent to the verification screen and then redirected to your profile.
2. **Sign In/Out**: Try signing out and then signing back in with the same credentials.
3. **Navigation**: Notice how the home screen shows different options based on authentication state - showing "Sign In" and "Sign Up" buttons when logged out, and "Go to Profile" when logged in.
4. **Error Handling**: Try signing in with invalid credentials to see error handling, or try signing up with a short password to see validation.
5. **Email Verification**: Test the complete email verification flow in both development and when deployed.
</Step>
</Steps>
## Key Features Demonstrated
<AccordionGroup>
<Accordion title="Complete Registration Flow" icon="user-plus">
Full email/password registration with proper form validation and user feedback optimized for mobile devices.
</Accordion>
<Accordion title="Email Verification" icon="envelope-circle-check">
Custom `/verify` screen that securely processes email verification tokens with Expo Linking integration.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling for unverified emails, failed authentication, and network issues with mobile-friendly alerts.
</Accordion>
<Accordion title="Session Management" icon="clock">
Complete sign out functionality with confirmation dialogs and proper session state management across the application.
</Accordion>
</AccordionGroup>
## React Native Adaptations Made
This tutorial demonstrates several key React Native patterns:
- **KeyboardAvoidingView**: Ensures forms remain accessible when keyboard is open
- **ActivityIndicator**: Native loading spinners for better performance
- **Alert.alert()**: Native confirmation dialogs for important actions
- **Expo Linking**: Proper deep linking for email verification
- **TouchableOpacity**: Native touch feedback for buttons
- **ScrollView**: Scrollable content containers with proper keyboard handling

View File

@@ -0,0 +1,881 @@
---
title: GraphQL Operations in React Native
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and React Native
sidebarTitle: "GraphQL Operations"
icon: code
---
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
<Info>
This is **Part 4** in the Full-Stack React Native Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
</Info>
## Full-Stack React Native Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
**Current** - CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
File upload and management
</Card>
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
Apple authentication integration
</Card>
</CardGroup>
## Prerequisites
- Complete the [User Authentication part](/getting-started/tutorials/reactnative/3-user-authentication) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- **GraphQL queries and mutations** for complete CRUD operations
- **Database schema** with proper relationships and constraints
- **User permissions** for secure data access control
- **React Native screens** that interact with GraphQL endpoint
## Step-by-Step Guide
<Steps>
<Step>
### Create the To-Dos Table
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Click on the SQL Editor
Enter the following SQL:
<Tabs>
<Tab title="SQL">
```sql
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
details text,
completed bool DEFAULT false NOT NULL,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_todos_updated_at
BEFORE UPDATE ON public.todos
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
```
</Tab>
<Tab title="UI">
![Database SQL Editor](/images/tutorials/todos/1.png)
</Tab>
</Tabs>
<Warning>
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
</Warning>
</Step>
<Step>
### Set Up Permissions
Its now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
<Tabs>
<Tab title="Insert">
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
![Insert Permissions Configuration](/images/tutorials/todos/2.png)
</Tab>
<Tab title="Select">
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
![Select Permissions Configuration](/images/tutorials/todos/3.png)
</Tab>
<Tab title="Update">
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
![Update Permissions Configuration](/images/tutorials/todos/4.png)
</Tab>
<Tab title="Delete">
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
![Delete Permissions Configuration](/images/tutorials/todos/5.png)
</Tab>
</Tabs>
</Step>
<Step>
### Create the Todos Screen Component
Now let's implement the React Native screen that uses the database we just configured.
```tsx app/todos.tsx lines
import { router, Stack } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles } from "./styles/commonStyles";
// The interfaces below define the structure of our data
// They are not strictly necessary but help with type safety
// Represents a single todo item
interface Todo {
id: string;
title: string;
details: string | null;
completed: boolean;
created_at: string;
updated_at: string;
user_id: string;
}
// This matches the GraphQL response structure for fetching todos
// Can be used as a generic type on the request method
interface GetTodos {
todos: Todo[];
}
// This matches the GraphQL response structure for inserting a todo
// Can be used as a generic type on the request method
interface InsertTodo {
insert_todos_one: Todo | null;
}
// This matches the GraphQL response structure for updating a todo
// Can be used as a generic type on the request method
interface UpdateTodo {
update_todos_by_pk: Todo | null;
}
export default function Todos() {
const { nhost, session } = useAuth();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newTodoTitle, setNewTodoTitle] = useState("");
const [newTodoDetails, setNewTodoDetails] = useState("");
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
const [addingTodo, setAddingTodo] = useState(false);
const [updatingTodos, setUpdatingTodos] = useState<Set<string>>(new Set());
// Redirect to sign in if not authenticated
useEffect(() => {
if (!session) {
router.replace("/signin");
}
}, [session]);
const fetchTodos = useCallback(async () => {
try {
setLoading(true);
// Make GraphQL request to fetch todos using Nhost client
// The query automatically filters by user_id due to Hasura permissions
const response = await nhost.graphql.request<GetTodos>({
query: `
query GetTodos {
todos(order_by: { created_at: desc }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
});
// Check for GraphQL errors in the response body
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch todos",
);
}
// Extract todos from the GraphQL response data
setTodos(response.body?.data?.todos || []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch todos");
} finally {
setLoading(false);
}
}, [nhost.graphql]);
const addTodo = async () => {
if (!newTodoTitle.trim()) return;
try {
setAddingTodo(true);
// Execute GraphQL mutation to insert a new todo
// user_id is automatically set by Hasura based on JWT token
const response = await nhost.graphql.request<InsertTodo>({
query: `
mutation InsertTodo($title: String!, $details: String) {
insert_todos_one(object: { title: $title, details: $details }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
title: newTodoTitle.trim(),
details: newTodoDetails.trim() || null,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to add todo",
);
}
if (!response.body?.data?.insert_todos_one) {
throw new Error("Failed to add todo");
}
setTodos([response.body?.data?.insert_todos_one, ...todos]);
setNewTodoTitle("");
setNewTodoDetails("");
setShowAddForm(false);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add todo");
Alert.alert(
"Error",
err instanceof Error ? err.message : "Failed to add todo",
);
} finally {
setAddingTodo(false);
}
};
const updateTodo = async (
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
) => {
try {
setUpdatingTodos((prev) => new Set([...prev, id]));
// Execute GraphQL mutation to update an existing todo by primary key
// Hasura permissions ensure users can only update their own todos
const response = await nhost.graphql.request<UpdateTodo>({
query: `
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
id,
updates,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to update todo",
);
}
if (!response.body?.data?.update_todos_by_pk) {
throw new Error("Failed to update todo");
}
const updatedTodo = response.body?.data?.update_todos_by_pk;
if (updatedTodo) {
setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
}
setEditingTodo(null);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update todo");
Alert.alert(
"Error",
err instanceof Error ? err.message : "Failed to update todo",
);
} finally {
setUpdatingTodos((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
};
const deleteTodo = async (id: string) => {
Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
try {
setUpdatingTodos((prev) => new Set([...prev, id]));
// Execute GraphQL mutation to delete a todo by primary key
// Hasura permissions ensure users can only delete their own todos
const response = await nhost.graphql.request({
query: `
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
variables: {
id,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to delete todo",
);
}
setTodos(todos.filter((todo) => todo.id !== id));
setError(null);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to delete todo",
);
Alert.alert(
"Error",
err instanceof Error ? err.message : "Failed to delete todo",
);
} finally {
setUpdatingTodos((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
},
},
]);
};
const toggleComplete = async (todo: Todo) => {
await updateTodo(todo.id, { completed: !todo.completed });
};
const saveEdit = async () => {
if (!editingTodo) return;
await updateTodo(editingTodo.id, {
title: editingTodo.title,
details: editingTodo.details,
});
};
const toggleTodoExpansion = (todoId: string) => {
const newExpanded = new Set(expandedTodos);
if (newExpanded.has(todoId)) {
newExpanded.delete(todoId);
} else {
newExpanded.add(todoId);
}
setExpandedTodos(newExpanded);
};
// Fetch todos when user session is available
// The session contains the JWT token needed for GraphQL authentication
useEffect(() => {
if (session) {
fetchTodos();
}
}, [session, fetchTodos]);
if (!session) {
return null; // Will redirect to sign in
}
const renderTodoItem = ({ item: todo }: { item: Todo }) => {
const isUpdating = updatingTodos.has(todo.id);
const isExpanded = expandedTodos.has(todo.id);
return (
<View
style={[
commonStyles.todoCard,
todo.completed && commonStyles.todoCompleted,
]}
>
{editingTodo?.id === todo.id ? (
<View style={commonStyles.todoEditForm}>
<Text style={commonStyles.inputLabel}>Title</Text>
<TextInput
style={commonStyles.input}
value={editingTodo.title}
onChangeText={(text) =>
setEditingTodo({
...editingTodo,
title: text,
})
}
placeholder="Enter todo title"
/>
<Text style={commonStyles.inputLabel}>Details</Text>
<TextInput
style={[commonStyles.input, commonStyles.textArea]}
value={editingTodo.details || ""}
onChangeText={(text) =>
setEditingTodo({
...editingTodo,
details: text,
})
}
placeholder="Enter details (optional)"
multiline
numberOfLines={3}
/>
<View style={commonStyles.buttonGroup}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.primaryButton]}
onPress={saveEdit}
disabled={isUpdating}
>
<Text style={commonStyles.buttonText}>
{isUpdating ? "Saving..." : "Save"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.secondaryButton]}
onPress={() => setEditingTodo(null)}
>
<Text
style={[
commonStyles.buttonText,
commonStyles.secondaryButtonText,
]}
>
Cancel
</Text>
</TouchableOpacity>
</View>
</View>
) : (
<View>
<View style={commonStyles.todoHeader}>
<TouchableOpacity
style={commonStyles.todoTitleContainer}
onPress={() => toggleTodoExpansion(todo.id)}
>
<Text
style={[
commonStyles.todoTitle,
todo.completed && commonStyles.todoTitleCompleted,
]}
>
{todo.title}
</Text>
</TouchableOpacity>
<View style={commonStyles.todoActions}>
<TouchableOpacity
style={[
commonStyles.actionButton,
commonStyles.completeButton,
]}
onPress={() => toggleComplete(todo)}
disabled={isUpdating}
>
<Text style={commonStyles.actionButtonText}>
{isUpdating ? "⌛" : todo.completed ? "↶" : "✓"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.actionButton, commonStyles.editButton]}
onPress={() => setEditingTodo(todo)}
>
<Text style={commonStyles.actionButtonText}>✏️</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.actionButton, commonStyles.deleteButton]}
onPress={() => deleteTodo(todo.id)}
disabled={isUpdating}
>
<Text style={commonStyles.actionButtonText}>🗑️</Text>
</TouchableOpacity>
</View>
</View>
{isExpanded && (
<View style={commonStyles.todoDetails}>
{todo.details && (
<Text
style={[
commonStyles.todoDescription,
todo.completed && commonStyles.todoDescriptionCompleted,
]}
>
{todo.details}
</Text>
)}
<View style={commonStyles.todoMeta}>
<Text style={commonStyles.metaText}>
Created: {new Date(todo.created_at).toLocaleString()}
</Text>
<Text style={commonStyles.metaText}>
Updated: {new Date(todo.updated_at).toLocaleString()}
</Text>
{todo.completed && (
<View style={commonStyles.completionBadge}>
<Text style={commonStyles.completionText}>
✅ Completed
</Text>
</View>
)}
</View>
</View>
)}
</View>
)}
</View>
);
};
const renderHeader = () => (
<>
<View style={commonStyles.pageHeader}>
<Text style={commonStyles.pageTitle}>My Todos</Text>
{!showAddForm && (
<TouchableOpacity
style={commonStyles.addButton}
onPress={() => setShowAddForm(true)}
>
<Text style={commonStyles.addButtonText}>+</Text>
</TouchableOpacity>
)}
</View>
{error && (
<View style={[commonStyles.errorContainer, { marginHorizontal: 16 }]}>
<Text style={commonStyles.errorText}>Error: {error}</Text>
</View>
)}
{showAddForm && (
<View style={[commonStyles.card, { marginHorizontal: 16 }]}>
<Text style={commonStyles.cardTitle}>Add New Todo</Text>
<View style={commonStyles.formFields}>
<View style={commonStyles.fieldGroup}>
<Text style={commonStyles.inputLabel}>Title *</Text>
<TextInput
style={commonStyles.input}
value={newTodoTitle}
onChangeText={setNewTodoTitle}
placeholder="What needs to be done?"
/>
</View>
<View style={commonStyles.fieldGroup}>
<Text style={commonStyles.inputLabel}>Details</Text>
<TextInput
style={[commonStyles.input, commonStyles.textArea]}
value={newTodoDetails}
onChangeText={setNewTodoDetails}
placeholder="Add some details (optional)..."
multiline
numberOfLines={3}
/>
</View>
<View style={commonStyles.buttonGroup}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.primaryButton]}
onPress={addTodo}
disabled={addingTodo || !newTodoTitle.trim()}
>
<Text style={commonStyles.buttonText}>
{addingTodo ? "Adding..." : "Add Todo"}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.secondaryButton]}
onPress={() => {
setShowAddForm(false);
setNewTodoTitle("");
setNewTodoDetails("");
}}
>
<Text
style={[
commonStyles.buttonText,
commonStyles.secondaryButtonText,
]}
>
Cancel
</Text>
</TouchableOpacity>
</View>
</View>
</View>
)}
</>
);
const renderEmptyState = () => (
<View style={commonStyles.emptyState}>
<Text style={commonStyles.emptyStateTitle}>No todos yet</Text>
<Text style={commonStyles.emptyStateText}>
Create your first todo to get started!
</Text>
</View>
);
if (loading) {
return (
<ProtectedScreen>
<Stack.Screen options={{ title: "My Todos" }} />
<View style={commonStyles.loadingContainer}>
<ActivityIndicator size="large" color="#6366f1" />
<Text style={commonStyles.loadingText}>Loading todos...</Text>
</View>
</ProtectedScreen>
);
}
return (
<ProtectedScreen>
<Stack.Screen options={{ title: "My Todos" }} />
<View style={commonStyles.container}>
<FlatList
data={showAddForm ? [] : todos}
renderItem={renderTodoItem}
keyExtractor={(item) => item.id}
ListHeaderComponent={renderHeader}
ListEmptyComponent={!showAddForm ? renderEmptyState : null}
showsVerticalScrollIndicator={false}
contentContainerStyle={commonStyles.listContainer}
/>
</View>
</ProtectedScreen>
);
}
```
</Step>
<Step>
### Update Home Screen Navigation
Since React Native uses file-based routing with Expo Router, you can add navigation to the todos screen from your home screen or any other screen using the `router.push()` method.
```tsx app/index.tsx lines highlight={44-49}
import { useRouter } from "expo-router";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, homeStyles } from "./styles/commonStyles";
export default function Index() {
const router = useRouter();
const { isAuthenticated, session, nhost, user } = useAuth();
const handleSignOut = async () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
router.replace("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
Alert.alert("Error", `Failed to sign out: ${message}`);
}
},
},
]);
};
return (
<View style={commonStyles.centerContent}>
<Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>
<View style={homeStyles.welcomeCard}>
{isAuthenticated ? (
<View style={{ gap: 15, width: "100%" }}>
<Text style={homeStyles.welcomeText}>
Hello, {user?.displayName || user?.email}!
</Text>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/todos")}
>
<Text style={commonStyles.buttonText}>My Todos</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/profile")}
>
<Text style={commonStyles.buttonText}>Go to Profile</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
onPress={handleSignOut}
>
<Text style={commonStyles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</View>
) : (
<>
<Text style={homeStyles.authMessage}>You are not signed in.</Text>
<View style={{ gap: 15, width: "100%" }}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/signin")}
>
<Text style={commonStyles.buttonText}>Sign In</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
commonStyles.button,
commonStyles.buttonSecondary,
commonStyles.fullWidth,
]}
onPress={() => router.push("/signup")}
>
<Text style={commonStyles.buttonText}>Sign Up</Text>
</TouchableOpacity>
</View>
</>
)}
</View>
</View>
);
}
```
</Step>
<Step>
### Test Your Complete Application
Run your React Native application and test all the functionality:
```bash
npx expo start
```
Things to try out:
1. Try signing in and out and see how the Todos screen is only available when authenticated
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly with React Native animations
3. Test the mobile experience - long press, swipe gestures, and keyboard interactions
4. Test on different devices/simulators to see how the todos look on various screen sizes
5. Try signing out and signing in with a different account to verify that you cannot see or modify todos from other accounts
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Database Schema" icon="database">
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
</Accordion>
<Accordion title="GraphQL API" icon="webhook">
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
</Accordion>
<Accordion title="Row-Level Security" icon="shield-check">
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
</Accordion>
<Accordion title="CRUD Operations" icon="arrows-rotate">
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
</Accordion>
<Accordion title="Native Mobile Interface" icon="sparkles">
Touch-friendly expandable todo items, inline editing with React Native keyboard handling, completion status, and detailed timestamps optimized for mobile screens.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,776 @@
---
title: File Uploads in React Native
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and React Native
sidebarTitle: "File Uploads"
icon: upload
---
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a React Native application.
<Info>
This is **Part 5** in the Full-Stack React Native Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
</Info>
## Full-Stack React Native Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Screens" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
Screen protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
**Current** - File upload and management
</Card>
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
Apple authentication integration
</Card>
</CardGroup>
## Prerequisites
- Complete the [GraphQL Operations part](/getting-started/tutorials/reactnative/4-graphql-operations) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- A **personal bucket** so users can upload their own private files
- **File upload functionality**
- **File management interface** for viewing and deleting files
- **Security permissions** ensuring users can only access their own files
## Step-by-Step Guide
<Steps>
<Step>
### Create a Personal Storage Bucket
First, we'll create a storage bucket where users can upload their personal files.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Change to **schema.storage**, then buckets
3. Now click on `+ Insert` on the top right corner.
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
![Create bucket](/images/tutorials/uploads/1.png)
</Step>
<Step>
### Configure Storage Permissions
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
<Tabs>
<Tab title="Upload">
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
![upload files permissions](/images/tutorials/uploads/2.png)
</Tab>
<Tab title="Download">
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
![download files permissions](/images/tutorials/uploads/3.png)
</Tab>
<Tab title="Delete">
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
![delete files permissions](/images/tutorials/uploads/4.png)
</Tab>
</Tabs>
<Info>
You can read more about storage permissions [here](/products/storage/overview#permissions)
</Info>
</Step>
<Step>
### Install Required Dependencies
First, let's install the dependencies needed for file handling in React Native.
```bash
npx expo install expo-document-picker@13 expo-file-system@18 expo-sharing@13
```
- **expo-document-picker**: For selecting files from device storage
- **expo-file-system**: For handling file system operations
- **expo-sharing**: For sharing/opening files with other apps
</Step>
<Step>
### Create the File Upload Screen Component
Now let's implement the React Native screen for file upload functionality using the shared theme from the protected routes tutorial.
```tsx app/files.tsx lines
import type { FetchError } from "@nhost/nhost-js/fetch";
import type { ErrorResponse, FileMetadata } from "@nhost/nhost-js/storage";
import * as DocumentPicker from "expo-document-picker";
import * as FileSystem from "expo-file-system";
import { Stack } from "expo-router";
import * as Sharing from "expo-sharing";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
FlatList,
Text,
TouchableOpacity,
View,
} from "react-native";
import ProtectedScreen from "./components/ProtectedScreen";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, fileUploadStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";
interface DeleteStatus {
message: string;
isError: boolean;
}
interface GraphqlGetFilesResponse {
files: FileMetadata[];
}
// Utility function to format file size
function formatFileSize(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
}
// Convert Blob to Base64 for React Native file handling
function blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const base64data = reader.result as string;
// Remove the data URL prefix (e.g., "data:application/octet-stream;base64,")
const base64Content = base64data.split(",")[1] || "";
resolve(base64Content);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
export default function Files() {
const { nhost } = useAuth();
const [selectedFile, setSelectedFile] =
useState<DocumentPicker.DocumentPickerResult | null>(null);
const [uploading, setUploading] = useState<boolean>(false);
const [uploadResult, setUploadResult] = useState<FileMetadata | null>(null);
const [isFetching, setIsFetching] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileMetadata[]>([]);
const [viewingFile, setViewingFile] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [deleteStatus, setDeleteStatus] = useState<DeleteStatus | null>(null);
const [isViewingInProgress, setIsViewingInProgress] =
useState<boolean>(false);
const fetchFiles = useCallback(async () => {
setIsFetching(true);
setError(null);
try {
// Fetch files using GraphQL query
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
query: `query GetFiles {
files {
id
name
size
mimeType
bucketId
uploadedByUserId
}
}`,
});
setFiles(response.body.data?.files || []);
} catch (err) {
const errMessage =
err instanceof Error ? err.message : "An unexpected error occurred";
setError(`Failed to fetch files: ${errMessage}`);
} finally {
setIsFetching(false);
}
}, [nhost.graphql]);
// Fetch existing files when component mounts
useEffect(() => {
void fetchFiles();
}, [fetchFiles]);
const pickDocument = async () => {
// Prevent DocumentPicker from opening if we're currently viewing a file
if (isViewingInProgress) {
return;
}
try {
const result = await DocumentPicker.getDocumentAsync({
type: "*/*", // All file types
copyToCacheDirectory: true,
});
if (!result.canceled) {
setSelectedFile(result);
setError(null);
setUploadResult(null);
}
} catch (err) {
setError("Failed to pick document");
console.error("DocumentPicker Error:", err);
}
};
const handleUpload = async () => {
if (!selectedFile || selectedFile.canceled) {
setError("Please select a file to upload");
return;
}
setUploading(true);
setError(null);
try {
// For React Native, we need to read the file first
const fileToUpload = selectedFile.assets?.[0];
if (!fileToUpload) {
throw new Error("No file selected");
}
const file: unknown = {
uri: fileToUpload.uri,
name: fileToUpload.name || "file",
type: fileToUpload.mimeType || "application/octet-stream",
};
// Upload file to the personal bucket
// The uploadedByUserId is automatically set by the storage permissions
const response = await nhost.storage.uploadFiles({
"bucket-id": "personal",
"file[]": [file as File],
});
// Get the processed file data
const uploadedFile = response.body.processedFiles?.[0];
if (uploadedFile === undefined) {
throw new Error("Failed to upload file");
}
setUploadResult(uploadedFile);
// Reset form
setSelectedFile(null);
// Update files list
setFiles((prevFiles) => [uploadedFile, ...prevFiles]);
// Refresh file list
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
setUploadResult(null);
}, 3000);
} catch (err: unknown) {
const error = err as FetchError<ErrorResponse>;
setError(`Failed to upload file: ${error.message}`);
console.error("Upload error:", err);
} finally {
setUploading(false);
}
};
// Function to handle viewing a file with proper authorization
const handleViewFile = async (
fileId: string,
fileName: string,
mimeType: string,
) => {
setViewingFile(fileId);
setIsViewingInProgress(true);
try {
// Fetch the file with authentication using the SDK
const response = await nhost.storage.getFile(fileId);
if (!response.body) {
throw new Error("Failed to retrieve file contents");
}
// For iOS/Android, we need to save the file to the device first
// Create a unique temp file path with a timestamp to prevent collisions
const timestamp = Date.now();
const tempFileName = fileName.includes(".")
? fileName
: `${fileName}.file`;
const tempFilePath = `${FileSystem.cacheDirectory}${timestamp}_${tempFileName}`;
// Get the blob from the response
const blob = response.body;
// Convert blob to base64
const base64Data = await blobToBase64(blob);
// Write the file to the filesystem
await FileSystem.writeAsStringAsync(tempFilePath, base64Data, {
encoding: FileSystem.EncodingType.Base64,
});
// Check if sharing is available (iOS & Android)
const isSharingAvailable = await Sharing.isAvailableAsync();
if (isSharingAvailable) {
// Open the file with the default app
await Sharing.shareAsync(tempFilePath, {
mimeType: mimeType || "application/octet-stream",
dialogTitle: `View ${fileName}`,
UTI: mimeType, // for iOS
});
// Clean up the temp file after sharing
try {
await FileSystem.deleteAsync(tempFilePath, { idempotent: true });
} catch (cleanupErr) {
console.warn("Failed to clean up temp file:", cleanupErr);
}
// Add a delay before allowing new document picker actions
// This prevents iOS from triggering file selection dialogs
setTimeout(() => {
setIsViewingInProgress(false);
}, 1000);
} else {
throw new Error("Sharing is not available on this device");
}
} catch (err) {
const error = err as FetchError<ErrorResponse>;
setError(`Failed to view file: ${error.message}`);
console.error("Error viewing file:", err);
Alert.alert("Error", `Failed to view file: ${error.message}`);
setIsViewingInProgress(false);
} finally {
setViewingFile(null);
}
};
// Function to handle deleting a file
const handleDeleteFile = (fileId: string) => {
if (!fileId || deleting) return;
// Confirm deletion
Alert.alert("Delete File", "Are you sure you want to delete this file?", [
{
text: "Cancel",
style: "cancel",
},
{
text: "Delete",
style: "destructive",
onPress: () => {
void (async () => {
setDeleting(fileId);
setError(null);
setDeleteStatus(null);
// Get the file name for the status message
const fileToDelete = files.find((file) => file.id === fileId);
const fileName = fileToDelete?.name || "File";
try {
// Delete the file using the Nhost storage SDK
// Permissions ensure users can only delete their own files
await nhost.storage.deleteFile(fileId);
// Show success message
setDeleteStatus({
message: `${fileName} deleted successfully`,
isError: false,
});
// Update the local files list by removing the deleted file
setFiles(files.filter((file) => file.id !== fileId));
// Refresh the file list
await fetchFiles();
// Clear the success message after 3 seconds
setTimeout(() => {
setDeleteStatus(null);
}, 3000);
} catch (err) {
// Show error message
const error = err as FetchError<ErrorResponse>;
setDeleteStatus({
message: `Failed to delete ${fileName}: ${error.message}`,
isError: true,
});
console.error("Error deleting file:", err);
} finally {
setDeleting(null);
}
})();
},
},
]);
};
return (
<ProtectedScreen>
<Stack.Screen options={{ title: "File Upload" }} />
<View style={commonStyles.container}>
{/* Upload Form */}
<View style={commonStyles.card}>
<Text style={commonStyles.cardTitle}>Upload a File</Text>
<TouchableOpacity
style={fileUploadStyles.fileUpload}
onPress={pickDocument}
>
<View style={fileUploadStyles.uploadIcon}>
<Text style={fileUploadStyles.uploadIconText}>⬆️</Text>
</View>
<Text style={fileUploadStyles.uploadText}>
Tap to select a file
</Text>
{selectedFile &&
!selectedFile.canceled &&
selectedFile.assets?.[0] && (
<Text style={fileUploadStyles.fileName}>
{selectedFile.assets[0].name} (
{formatFileSize(selectedFile.assets[0].size || 0)})
</Text>
)}
</TouchableOpacity>
{error && (
<View style={commonStyles.errorContainer}>
<Text style={commonStyles.errorText}>{error}</Text>
</View>
)}
{uploadResult && (
<View style={commonStyles.successContainer}>
<Text style={commonStyles.successText}>
File uploaded successfully!
</Text>
</View>
)}
<TouchableOpacity
style={[
commonStyles.button,
(!selectedFile || selectedFile.canceled || uploading) &&
fileUploadStyles.buttonDisabled,
]}
onPress={handleUpload}
disabled={!selectedFile || selectedFile.canceled || uploading}
>
<Text style={commonStyles.buttonText}>
{uploading ? "Uploading..." : "Upload File"}
</Text>
</TouchableOpacity>
</View>
{/* Files List */}
<View style={commonStyles.card}>
<Text style={commonStyles.cardTitle}>Your Files</Text>
{deleteStatus && (
<View
style={[
deleteStatus.isError
? commonStyles.errorContainer
: commonStyles.successContainer,
]}
>
<Text
style={
deleteStatus.isError
? commonStyles.errorText
: commonStyles.successText
}
>
{deleteStatus.message}
</Text>
</View>
)}
{isFetching ? (
<View style={commonStyles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={commonStyles.loadingText}>Loading files...</Text>
</View>
) : files.length === 0 ? (
<View style={fileUploadStyles.emptyState}>
<Text style={fileUploadStyles.emptyIcon}>📄</Text>
<Text style={fileUploadStyles.emptyTitle}>No files yet</Text>
<Text style={fileUploadStyles.emptyDescription}>
Upload your first file to get started!
</Text>
</View>
) : (
<FlatList
data={files}
keyExtractor={(item) => item.id || Math.random().toString()}
renderItem={({ item }) => (
<View style={fileUploadStyles.fileItem}>
<View style={fileUploadStyles.fileInfo}>
<Text
style={fileUploadStyles.fileNameText}
numberOfLines={1}
>
{item.name}
</Text>
<Text style={fileUploadStyles.fileDetails}>
{item.mimeType} • {formatFileSize(item.size || 0)}
</Text>
</View>
<View style={fileUploadStyles.fileActions}>
<TouchableOpacity
style={fileUploadStyles.actionButton}
onPress={() =>
handleViewFile(
item.id || "unknown",
item.name || "unknown",
item.mimeType || "unknown",
)
}
disabled={viewingFile === item.id}
>
{viewingFile === item.id ? (
<Text style={fileUploadStyles.actionText}>⌛</Text>
) : (
<Text style={fileUploadStyles.actionText}>👁️</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[
fileUploadStyles.actionButton,
fileUploadStyles.deleteButton,
]}
onPress={() => handleDeleteFile(item.id || "unknown")}
disabled={deleting === item.id}
>
{deleting === item.id ? (
<Text style={fileUploadStyles.actionText}>⌛</Text>
) : (
<Text style={fileUploadStyles.actionText}>🗑️</Text>
)}
</TouchableOpacity>
</View>
</View>
)}
style={fileUploadStyles.fileList}
/>
)}
</View>
</View>
</ProtectedScreen>
);
}
```
</Step>
<Step>
### Update Home Screen Navigation
Update the home screen to include navigation to the new file upload screen.
```tsx app/index.tsx lines highlight={51-56}
import { useRouter } from "expo-router";
import { Alert, Text, TouchableOpacity, View } from "react-native";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles, homeStyles } from "./styles/commonStyles";
export default function Index() {
const router = useRouter();
const { isAuthenticated, session, nhost, user } = useAuth();
const handleSignOut = async () => {
Alert.alert("Sign Out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: async () => {
try {
if (session) {
await nhost.auth.signOut({
refreshToken: session.refreshToken,
});
}
router.replace("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
Alert.alert("Error", `Failed to sign out: ${message}`);
}
},
},
]);
};
return (
<View style={commonStyles.centerContent}>
<Text style={commonStyles.title}>Welcome to Nhost React Native Demo</Text>
<View style={homeStyles.welcomeCard}>
{isAuthenticated ? (
<View style={{ gap: 15, width: "100%" }}>
<Text style={homeStyles.welcomeText}>
Hello, {user?.displayName || user?.email}!
</Text>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/todos")}
>
<Text style={commonStyles.buttonText}>My Todos</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/files")}
>
<Text style={commonStyles.buttonText}>My Files</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/profile")}
>
<Text style={commonStyles.buttonText}>Go to Profile</Text>
</TouchableOpacity>
<TouchableOpacity
style={[commonStyles.button, { backgroundColor: "#ef4444" }]}
onPress={handleSignOut}
>
<Text style={commonStyles.buttonText}>Sign Out</Text>
</TouchableOpacity>
</View>
) : (
<>
<Text style={homeStyles.authMessage}>You are not signed in.</Text>
<View style={{ gap: 15, width: "100%" }}>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.push("/signin")}
>
<Text style={commonStyles.buttonText}>Sign In</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
commonStyles.button,
commonStyles.buttonSecondary,
commonStyles.fullWidth,
]}
onPress={() => router.push("/signup")}
>
<Text style={commonStyles.buttonText}>Sign Up</Text>
</TouchableOpacity>
</View>
</>
)}
</View>
</View>
);
}
```
</Step>
<Step>
### Test Your File Upload System
Run your React Native application and test all the functionality:
```bash
npm start
```
Things to try out:
1. Try signing in and out and see how the file upload screen is only accessible when signed in.
2. Upload different types of files (images, documents, PDFs, etc.)
3. View files using the native sharing functionality
4. Delete files with confirmation dialogs
5. Sign in with another account and verify you cannot see files from the first account
6. Test on both iOS and Android if available
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Personal Storage Bucket" icon="bucket">
Dedicated personal storage bucket with proper configuration for user file isolation using the "personal" bucket.
</Accordion>
<Accordion title="Native File Upload Interface" icon="upload">
React Native file selection using Expo DocumentPicker with support for all file types and visual feedback.
</Accordion>
<Accordion title="Mobile File Management" icon="folder">
Complete file listing with FlatList, file metadata display, native viewing, and deletion with confirmation dialogs.
</Accordion>
<Accordion title="Native File Handling" icon="file">
Platform-specific file handling using Expo FileSystem and Sharing for viewing files with appropriate native apps.
</Accordion>
<Accordion title="Row-Level Security" icon="shield-check">
Comprehensive storage permissions ensuring users can only upload, view, and delete their own files through GraphQL and storage APIs.
</Accordion>
<Accordion title="Error Handling & UX" icon="triangle-exclamation">
Native Alert dialogs, loading states, and user-friendly error messages optimized for mobile interfaces.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,684 @@
---
title: Sign in with Apple in React Native
description: Learn how to implement Sign in with Apple authentication in your React Native app using Nhost Auth with proper configuration and native integration
sidebarTitle: "Sign in with Apple"
icon: apple
---
This tutorial extends the authentication system built in the previous parts by adding Sign in with Apple functionality. You'll learn how to configure Apple Sign In in the Nhost dashboard, implement the native iOS integration, and handle the authentication flow in your React Native application.
<Info>
This is **Part 6** in the Full-Stack React Native Development with Nhost series. This part focuses on integrating Apple's native authentication system with your existing Nhost authentication flow.
</Info>
## Full-Stack React Native Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/reactnative/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/reactnative/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/reactnative/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/reactnative/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/reactnative/5-file-uploads">
File upload and management
</Card>
<Card title="6. Sign in with Apple" icon="apple" href="/getting-started/tutorials/reactnative/6-sign-in-with-apple">
**Current** - Apple authentication integration
</Card>
</CardGroup>
## Prerequisites
- Complete the [User Authentication part](/getting-started/tutorials/reactnative/3-user-authentication) first
- Access to an Apple Developer account (required for Sign in with Apple)
- An iOS device or simulator for testing (Sign in with Apple requires iOS)
- The project from the previous parts set up and running
## What You'll Build
By the end of this part, you'll have:
- **Apple Developer Console configuration** for Sign in with Apple
- **Nhost Auth provider setup** for Apple authentication
- **Native Sign in with Apple button** in your React Native app
- **Seamless integration** with your existing authentication flow
- **Error handling** for Apple Sign In specific scenarios
## Step-by-Step Guide
<Steps>
<Step>
### Configure Apple Developer Console
Before we start we need to configure Apple Sign In. To do so, follow the [sign in with Apple](/products/auth/social/sign-in-apple) guide. In addition, you will have to configure the audience in the nhost dashboard. To do so, set the audience with your application's bundle identifier (e.g. `com.yourcompany.yourapp`).
<Warning>
If you are testing this flow in Expo Go, you will need to configure the audience to be `host.exp.Exponent`.
</Warning>
</Step>
<Step>
### Install Required Dependencies
Install the necessary packages for Apple Sign In in React Native:
```bash
npx expo install expo-apple-authentication@7 expo-crypto@14
```
The required packages provide:
- **expo-apple-authentication**: Native Apple Sign In functionality and Apple-styled sign-in buttons
- **expo-crypto**: Cryptographic utilities needed for secure nonce generation and hashing
</Step>
<Step>
### Update App Configuration
Add the Sign in with Apple configuration to your `app.json` or `app.config.js`:
```json app.json lines highlight={11-23,52}
{
"expo": {
"name": "nhost-reactnative-tutorial",
"slug": "nhost-reactnative-tutorial",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "nhostreactnativetutorial",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "io.example.nhost-reactnative-tutorial",
"jsEngine": "jsc",
"infoPlist": {
"NSFaceIDUsageDescription": "This app uses Face ID for signing in",
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": ["nhost-reactnative-tutorial"]
}
]
}
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
["expo-apple-authentication"]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"NHOST_REGION": <region>,
"NHOST_SUBDOMAIN": <subdomain>
}
}
}
```
<Important>
Make sure the `bundleIdentifier` matches the App ID you configured in the Apple Developer Console, and set `usesAppleSignIn` to `true`.
</Important>
</Step>
<Step>
### Create Apple Sign In Component
Create a reusable component for Apple Sign In functionality following the ReactNativeDemo approach:
```tsx app/components/AppleSignInButton.tsx
import * as AppleAuthentication from "expo-apple-authentication";
import * as Crypto from "expo-crypto";
import { useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { Alert, Platform, StyleSheet } from "react-native";
import { useAuth } from "../lib/nhost/AuthProvider";
interface AppleSignInButtonProps {
isLoading: boolean;
setIsLoading: (isLoading: boolean) => void;
}
export default function AppleSignInButton({
setIsLoading,
}: AppleSignInButtonProps) {
const { nhost } = useAuth();
const router = useRouter();
const [appleAuthAvailable, setAppleAuthAvailable] = useState(false);
useEffect(() => {
const checkAvailability = async () => {
if (Platform.OS === "ios") {
const isAvailable = await AppleAuthentication.isAvailableAsync();
setAppleAuthAvailable(isAvailable);
}
};
void checkAvailability();
}, []);
const handleAppleSignIn = async () => {
try {
setIsLoading(true);
// Generate a random nonce for security
const nonce = Math.random().toString(36).substring(2, 15);
// Hash the nonce for Apple Authentication
const hashedNonce = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
nonce,
);
// Request Apple authentication with our hashed nonce
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
nonce: hashedNonce,
});
if (credential.identityToken) {
// Use the identity token to sign in with Nhost
// Pass the original unhashed nonce to the SDK
// so the server can verify it
const response = await nhost.auth.signInIdToken({
provider: "apple",
idToken: credential.identityToken,
nonce,
});
if (response.body?.session) {
router.replace("/profile");
} else {
Alert.alert(
"Authentication Error",
"Failed to authenticate with Nhost",
);
}
} else {
Alert.alert(
"Authentication Error",
"No identity token received from Apple",
);
}
} catch (error: unknown) {
// Handle user cancellation gracefully
if (error instanceof Error && error.message.includes("canceled")) {
// User cancelled the sign-in flow, don't show an error
return;
}
// Handle other errors
const message =
error instanceof Error
? error.message
: "Failed to authenticate with Apple";
Alert.alert("Authentication Error", message);
} finally {
setIsLoading(false);
}
};
// Only show the button on iOS devices where Apple authentication is available
if (Platform.OS !== "ios" || !appleAuthAvailable) {
return null;
}
return (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={5}
style={styles.appleButton}
onPress={handleAppleSignIn}
/>
);
}
const styles = StyleSheet.create({
appleButton: {
width: "100%",
height: 45,
marginBottom: 10,
},
});
```
</Step>
<Step>
### Update Sign In Screen
Now integrate the Apple Sign In button into your existing sign-in screen following the tutorial structure:
```tsx app/signin.tsx lines highlight={13,70-81}
import { Link, router } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import AppleSignInButton from "./components/AppleSignInButton";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";
export default function SignIn() {
const { nhost, isAuthenticated } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Use useEffect for navigation after authentication is confirmed
useEffect(() => {
if (isAuthenticated) {
router.replace("/profile");
}
}, [isAuthenticated]);
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
try {
// Use the signIn function from auth context
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
// If we have a session, sign in was successful
if (response.body?.session) {
router.replace("/profile");
} else {
setError("Failed to sign in. Please check your credentials.");
}
} catch (err) {
const message = (err as Error).message || "Unknown error";
setError(`An error occurred during sign in: ${message}`);
} finally {
setIsLoading(false);
}
};
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={commonStyles.container}
>
<ScrollView
contentContainerStyle={commonStyles.centerContent}
keyboardShouldPersistTaps="handled"
>
<Text style={commonStyles.title}>Sign In</Text>
<View style={commonStyles.card}>
{/* Apple Sign In Button */}
<AppleSignInButton
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
{/* Divider */}
<View style={commonStyles.dividerContainer}>
<View style={commonStyles.divider} />
<Text style={commonStyles.dividerText}>or</Text>
<View style={commonStyles.divider} />
</View>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Email</Text>
<TextInput
style={commonStyles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Password</Text>
<TextInput
style={commonStyles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
/>
</View>
{error && (
<View style={commonStyles.errorContainer}>
<Text style={commonStyles.errorText}>{error}</Text>
</View>
)}
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color={colors.surface} />
) : (
<Text style={commonStyles.buttonText}>Sign In</Text>
)}
</TouchableOpacity>
</View>
<View style={commonStyles.linkContainer}>
<Text style={commonStyles.linkText}>
Don't have an account?{" "}
<Link href="/signup" style={commonStyles.link}>
Sign Up
</Link>
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
```
</Step>
<Step>
### Update Sign Up Screen
Also add Apple Sign In to your sign-up screen for consistency:
```tsx app/signup.tsx lines highlight={14,103-114}
import * as Linking from "expo-linking";
import { Link, router } from "expo-router";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView,
Text,
TextInput,
TouchableOpacity,
View,
} from "react-native";
import AppleSignInButton from "./components/AppleSignInButton";
import { useAuth } from "./lib/nhost/AuthProvider";
import { commonStyles } from "./styles/commonStyles";
import { colors } from "./styles/theme";
export default function SignUp() {
const { nhost, isAuthenticated } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Redirect authenticated users to profile
useEffect(() => {
if (isAuthenticated) {
router.replace("/profile");
}
}, [isAuthenticated]);
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
setSuccess(false);
try {
const response = await nhost.auth.signUpEmailPassword({
email,
password,
options: {
displayName,
// Set the redirect URL for email verification
redirectTo: Linking.createURL("verify"),
},
});
if (response.body?.session) {
// Successfully signed up and automatically signed in
router.replace("/profile");
} else {
// Verification email sent
setSuccess(true);
}
} catch (err) {
const message = (err as Error).message || "Unknown error";
setError(`An error occurred during sign up: ${message}`);
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<View style={commonStyles.centerContent}>
<Text style={commonStyles.title}>Check Your Email</Text>
<View style={commonStyles.successContainer}>
<Text style={commonStyles.successText}>
We've sent a verification link to{" "}
<Text style={commonStyles.emailText}>{email}</Text>
</Text>
<Text style={[commonStyles.bodyText, commonStyles.textCenter]}>
Please check your email and click the verification link to activate
your account.
</Text>
</View>
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={() => router.replace("/signin")}
>
<Text style={commonStyles.buttonText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
);
}
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={commonStyles.container}
>
<ScrollView
contentContainerStyle={commonStyles.centerContent}
keyboardShouldPersistTaps="handled"
>
<Text style={commonStyles.title}>Sign Up</Text>
<View style={commonStyles.card}>
{/* Apple Sign In Button */}
<AppleSignInButton
isLoading={isLoading}
setIsLoading={setIsLoading}
/>
{/* Divider */}
<View style={commonStyles.dividerContainer}>
<View style={commonStyles.divider} />
<Text style={commonStyles.dividerText}>or</Text>
<View style={commonStyles.divider} />
</View>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Display Name</Text>
<TextInput
style={commonStyles.input}
value={displayName}
onChangeText={setDisplayName}
placeholder="Enter your name"
autoCapitalize="words"
/>
</View>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Email</Text>
<TextInput
style={commonStyles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
<View style={commonStyles.formField}>
<Text style={commonStyles.labelText}>Password</Text>
<TextInput
style={commonStyles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
/>
<Text style={commonStyles.helperText}>Minimum 8 characters</Text>
</View>
{error && (
<View style={commonStyles.errorContainer}>
<Text style={commonStyles.errorText}>{error}</Text>
</View>
)}
<TouchableOpacity
style={[commonStyles.button, commonStyles.fullWidth]}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color={colors.surface} />
) : (
<Text style={commonStyles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</View>
<View style={commonStyles.linkContainer}>
<Text style={commonStyles.linkText}>
Already have an account?{" "}
<Link href="/signin" style={commonStyles.link}>
Sign In
</Link>
</Text>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
```
</Step>
<Step>
### Test Sign in with Apple
Build and test your application on an iOS device or simulator:
```bash
npm start
```
Things to test:
1. **Apple Sign In Flow**: Tap the "Sign in with Apple" button and complete the authentication
2. **User Creation**: Sign in with Apple for the first time to create a new user account
3. **Returning Users**: Sign in again with the same Apple ID to verify user recognition
4. **Profile Information**: Check that user name and email are properly populated
5. **Integration**: Verify that Apple Sign In users can access todos, files, and other protected features
6. **Error Handling**: Test network errors and authentication failures
<Note>
Sign in with Apple only works on physical iOS devices or iOS simulators running iOS 13+. It will not work on Android or web platforms in this implementation.
</Note>
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Apple Developer Configuration" icon="gear">
Complete setup of Apple Developer Console including App ID configuration, Service ID creation, and private key generation for secure authentication.
</Accordion>
<Accordion title="Nhost Provider Integration" icon="link">
Seamless integration with Nhost Auth system using Apple's identity tokens and authorization codes for secure user authentication.
</Accordion>
<Accordion title="Native iOS Integration" icon="mobile">
Native Apple Sign In button using expo-apple-authentication with proper iOS styling and user experience guidelines.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling for Apple Sign In failures, network issues, and user cancellations with appropriate user feedback.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,116 @@
---
title: Create Your Nhost Project
description: Learn how to create and set up a new Nhost project to get started building your SvelteKit application
sidebarTitle: Create Project
icon: plus
---
Welcome to the **Full-Stack SvelteKit Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete SvelteKit application with Nhost that demonstrates authentication, database operations, and file management.
## About This Tutorial Series
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern web applications with Nhost and SvelteKit. By the end of the series, you'll have built a fully functional application featuring:
- **User Authentication** - Complete sign up, sign in, and email verification flow
- **Todo Management** - Users can create, update, delete, and mark todos as complete
- **File Uploads** - Users can upload and manage files with proper permissions
- **Protected Routes** - Secure areas that only authenticated users can access
<Info>
This is **Part 1** in the Full-Stack SvelteKit Development with Nhost series. This part sets up the foundation by creating your Nhost project and understanding the series structure.
</Info>
## Full-Stack SvelteKit Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/svelte/1-introduction">
**Current** - Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/svelte/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/svelte/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/svelte/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/svelte/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## What You'll Learn
Throughout this series, you'll master:
- Setting up and configuring Nhost projects
- Implementing secure authentication flows
- Building protected routes with SvelteKit
- Performing GraphQL queries and mutations
- Managing file uploads and storage
- Configuring database permissions and security
- Building responsive SvelteKit interfaces
## Prerequisites
- Node.js 20+ installed on your machine
- Basic knowledge of Svelte and JavaScript
- Understanding of modern web development concepts
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
## Step-by-Step Guide
<Steps>
<Step>
### Sign Up or Log in
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
![sign up/sign in](/images/tutorials/create-nhost-project/1.png)
</Step>
<Step>
### Create a New Project
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
![2](/images/tutorials/create-nhost-project/2.png)
</Step>
<Step>
### Take note of your project subdomain and region
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
![3](/images/tutorials/create-nhost-project/3.png)
</Step>
</Steps>
## What's Next?
With your Nhost project created, you now have access to:
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
- [**File Storage**](/products/storage/overview) - For uploading and managing files
- [**Functions**](/products/functions/overview) - For running serverless functions
In the [next tutorial](/getting-started/tutorials/svelte/2-protected-routes), you'll start building your SvelteKit application and learn how to protect routes based on user authentication status.
<Tip>
Keep your project subdomain and region handy - you'll need them throughout the series to connect your SvelteKit application to the Nhost backend.
</Tip>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,578 @@
---
title: User Authentication in SvelteKit
description: Learn how to implement user authentication in a SvelteKit application using Nhost
sidebarTitle: "User Authentication"
icon: user
---
This tutorial part builds upon the [Protected Routes part](/getting-started/tutorials/svelte/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
<Info>
This is **Part 3** in the Full-Stack SvelteKit Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling.
</Info>
## Full-Stack SvelteKit Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/svelte/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/svelte/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/svelte/3-user-authentication">
**Current** - Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/svelte/4-graphql-operations">
CRUD operations with GraphQL
</Card>
</CardGroup>
## Prerequisites
- Complete the [Protected Routes part](/getting-started/tutorials/svelte/2-protected-routes) first
- The project from the previous part set up and running
## Step-by-Step Guide
<Steps>
<Step>
### Create the Sign In Page
Build a comprehensive sign-in form with proper error handling and loading states. This page handles user authentication and includes special logic for post-verification sign-in.
```svelte src/routes/signin/+page.svelte lines
<script lang="ts">
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import { goto } from "$app/navigation";
import { auth, nhost } from "$lib/nhost/auth";
let email = $state("");
let password = $state("");
let isLoading = $state(false);
let error = $state<string | null>(null);
// Navigate to profile when authenticated
$effect(() => {
if ($auth.isAuthenticated) {
void goto("/profile");
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
isLoading = true;
error = null;
try {
// Use the signIn function from auth context
const response = await nhost.auth.signInEmailPassword({
email,
password,
});
// If we have a session, sign in was successful
if (response.body?.session) {
void goto("/profile");
} else {
error = "Failed to sign in. Please check your credentials.";
}
} catch (err) {
const fetchError = err as FetchError<ErrorResponse>;
error = `An error occurred during sign in: ${fetchError.message}`;
} finally {
isLoading = false;
}
}
</script>
<div>
<h1>Sign In</h1>
<form onsubmit={handleSubmit} class="auth-form">
<div class="auth-form-field">
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={email}
required
class="auth-input"
/>
</div>
<div class="auth-form-field">
<label for="password">Password</label>
<input
id="password"
type="password"
bind:value={password}
required
class="auth-input"
/>
</div>
{#if error}
<div class="auth-error">
{error}
</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="auth-button secondary"
>
{isLoading ? "Signing In..." : "Sign In"}
</button>
</form>
<div class="auth-links">
<p>
Don't have an account? <a href="/signup">Sign Up</a>
</p>
</div>
</div>
```
</Step>
<Step>
### Create the Sign Up Page
Implement user registration with email verification flow. This page collects user information, creates accounts, and guides users through the email verification process.
```svelte src/routes/signup/+page.svelte lines
<script lang="ts">
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import { goto } from "$app/navigation";
import { auth, nhost } from "$lib/nhost/auth";
let email = $state("");
let password = $state("");
let displayName = $state("");
let isLoading = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
// If already authenticated, redirect to profile
$effect(() => {
if ($auth.isAuthenticated) {
void goto("/profile");
}
});
async function handleSubmit(e: Event) {
e.preventDefault();
isLoading = true;
error = null;
success = false;
try {
const response = await nhost.auth.signUpEmailPassword({
email,
password,
options: {
displayName,
// Set the redirect URL for email verification
redirectTo: `${window.location.origin}/verify`,
},
});
if (response.body?.session) {
// Successfully signed up and automatically signed in
void goto("/profile");
} else {
// Verification email sent
success = true;
}
} catch (err) {
const fetchError = err as FetchError<ErrorResponse>;
error = `An error occurred during sign up: ${fetchError.message}`;
} finally {
isLoading = false;
}
}
</script>
{#if success}
<div>
<h1>Check Your Email</h1>
<div class="success-message">
<p>
We've sent a verification link to <strong>{email}</strong>
</p>
<p>
Please check your email and click the verification link to activate your account.
</p>
</div>
<p>
<a href="/signin">Back to Sign In</a>
</p>
</div>
{:else}
<div>
<h1>Sign Up</h1>
<form onsubmit={handleSubmit} class="auth-form">
<div class="auth-form-field">
<label for="displayName">Display Name</label>
<input
id="displayName"
type="text"
bind:value={displayName}
required
class="auth-input"
/>
</div>
<div class="auth-form-field">
<label for="email">Email</label>
<input
id="email"
type="email"
bind:value={email}
required
class="auth-input"
/>
</div>
<div class="auth-form-field">
<label for="password">Password</label>
<input
id="password"
type="password"
bind:value={password}
required
minlength="8"
class="auth-input"
/>
<small class="help-text">Minimum 8 characters</small>
</div>
{#if error}
<div class="auth-error">
{error}
</div>
{/if}
<button
type="submit"
disabled={isLoading}
class="auth-button primary"
>
{isLoading ? "Creating Account..." : "Sign Up"}
</button>
</form>
<div class="auth-links">
<p>
Already have an account? <a href="/signin">Sign In</a>
</p>
</div>
</div>
{/if}
```
</Step>
<Step>
### Create the Email Verification Page
Build a dedicated verification page that processes email verification tokens. This page handles the verification flow when users click the email verification link.
```svelte src/routes/verify/+page.svelte lines
<script lang="ts">
import type { ErrorResponse } from "@nhost/nhost-js/auth";
import type { FetchError } from "@nhost/nhost-js/fetch";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { nhost } from "$lib/nhost/auth";
let status: "verifying" | "success" | "error" = "verifying";
let error = "";
let urlParams: Record<string, string> = {};
onMount(() => {
// Extract the refresh token from the URL
const params = new URLSearchParams($page.url.search);
const refreshToken = params.get("refreshToken");
if (!refreshToken) {
// Collect all URL parameters to display for debugging
const allParams: Record<string, string> = {};
params.forEach((value, key) => {
allParams[key] = value;
});
urlParams = allParams;
status = "error";
error = "No refresh token found in URL";
return;
}
// Flag to handle component unmounting during async operations
let isMounted = true;
async function processToken() {
try {
// First display the verifying message for at least a moment
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isMounted) return;
if (!refreshToken) {
// Collect all URL parameters to display
const allParams: Record<string, string> = {};
params.forEach((value, key) => {
allParams[key] = value;
});
urlParams = allParams;
status = "error";
error = "No refresh token found in URL";
return;
}
// Process the token
await nhost.auth.refreshToken({ refreshToken });
if (!isMounted) return;
status = "success";
// Wait to show success message briefly, then redirect
setTimeout(() => {
if (isMounted) void goto("/profile");
}, 1500);
} catch (err) {
const fetchError = err as FetchError<ErrorResponse>;
if (!isMounted) return;
status = "error";
error = `An error occurred during verification: ${fetchError.message}`;
}
}
void processToken();
// Cleanup function
return () => {
isMounted = false;
};
});
</script>
<div>
<h1>Email Verification</h1>
<div class="page-center">
{#if status === "verifying"}
<div>
<p class="margin-bottom">Verifying your email...</p>
<div class="spinner-verify" />
</div>
{/if}
{#if status === "success"}
<div>
<p class="verification-status">
✓ Successfully verified!
</p>
<p>You'll be redirected to your profile page shortly...</p>
</div>
{/if}
{#if status === "error"}
<div>
<p class="verification-status error">
Verification failed
</p>
<p class="margin-bottom">{error}</p>
{#if Object.keys(urlParams).length > 0}
<div class="debug-panel">
<p class="debug-title">
URL Parameters:
</p>
{#each Object.entries(urlParams) as [key, value] (key)}
<div class="debug-item">
<span class="debug-key">
{key}:
</span>
<span class="debug-value">{value}</span>
</div>
{/each}
</div>
{/if}
<button
type="button"
onclick={() => goto("/signin")}
class="auth-button secondary"
>
Back to Sign In
</button>
</div>
{/if}
</div>
</div>
<style>
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.spinner-verify {
width: 2rem;
height: 2rem;
border: 2px solid transparent;
border-top: 2px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
</style>
```
<Warning>
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
1. Go to your Nhost project dashboard
2. Navigate to **Settings → Authentication**
3. Add your local development URL (e.g., `http://localhost:5173`) to the **Allowed Redirect URLs** field
4. Ensure your production domain is also added when deploying
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
</Warning>
</Step>
<Step>
### Update the Layout Component to Include New Routes
Configure your application's routing structure to include the new authentication pages. In SvelteKit, routes are automatically created based on the file structure, so you'll update the layout component to handle authentication state properly.
```svelte src/routes/+layout.svelte lines highlight={20-27,40-45,47-52}
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { auth, initializeAuth, nhost } from "$lib/nhost/auth";
import "../app.css";
let { children }: { children?: import("svelte").Snippet } = $props();
// Initialize auth when component mounts
onMount(() => {
return initializeAuth();
});
// Helper function to determine if a link is active
function isActive(path: string): string {
return $page.url.pathname === path ? "nav-link active" : "nav-link";
}
async function handleSignOut() {
if ($auth.session) {
await nhost.auth.signOut({
refreshToken: $auth.session.refreshToken,
});
void goto("/");
}
}
</script>
<div id="root">
<nav class="navigation">
<div class="nav-container">
<a href="/" class="nav-logo">Nhost SvelteKit Demo</a>
<div class="nav-links">
<a href="/" class="nav-link">Home</a>
{#if $auth.isAuthenticated}
<a href="/profile" class={isActive('/profile')}>Profile</a>
<button
onclick={handleSignOut}
class="nav-link nav-button"
>
Sign Out
</button>
{:else}
<a href="/signin" class="nav-link {isActive('/signin')}">
Sign In
</a>
<a href="/signup" class="nav-link {isActive('/signup')}">
Sign Up
</a>
{/if}
</div>
</div>
</nav>
<div class="app-content">
{#if children}
{@render children()}
{/if}
</div>
</div>
```
</Step>
<Step>
### Run and Test the Application
Start your development server and test the complete authentication flow to ensure everything works properly.
```bash
npm run dev
```
Things to try out:
1. Try signing up with a new email address. Check your email for the verification link and click it. See how you are sent to the verification page and then redirected to your profile.
2. Try signing out and then signing back in with the same credentials.
3. Notice how navigation links change based on authentication state showing "Sign In" and "Sign Up" when logged out, and "Profile" and "Sign Out" when logged in.
4. Check how the homepage also reflects the authentication state with appropriate messages.
5. Open multiple tabs and test signing out from one tab to see how other tabs respond. Now sign back in and see the changes propagate across tabs.
</Step>
</Steps>
## Key Features Demonstrated
<AccordionGroup>
<Accordion title="Complete Registration Flow" icon="user-plus">
Full email/password registration with proper form validation and user feedback using SvelteKit's reactive state management.
</Accordion>
<Accordion title="Email Verification" icon="envelope-circle-check">
Custom `/verify` route that securely processes email verification tokens using SvelteKit's page stores and navigation utilities.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling for unverified emails, failed authentication, and network issues with proper TypeScript error typing.
</Accordion>
<Accordion title="Visual Feedback" icon="eye">
Loading states, success messages, and clear error displays throughout the authentication flow using Svelte's reactive declarations.
</Accordion>
<Accordion title="Session Management" icon="clock">
Complete sign out functionality and proper session state management across the application using Svelte stores and cross-tab synchronization.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,763 @@
---
title: GraphQL Operations in SvelteKit
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and SvelteKit
sidebarTitle: "GraphQL Operations"
icon: code
---
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
<Info>
This is **Part 4** in the Full-Stack SvelteKit Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
</Info>
## Full-Stack SvelteKit Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/svelte/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/svelte/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/svelte/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/svelte/4-graphql-operations">
**Current** - CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/svelte/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [User Authentication part](/getting-started/tutorials/svelte/3-user-authentication) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- **GraphQL queries and mutations** for complete CRUD operations
- **Database schema** with proper relationships and constraints
- **User permissions** for secure data access control
- **SvelteKit components** that interact with GraphQL endpoint
## Step-by-Step Guide
<Steps>
<Step>
### Create the To-Dos Table
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Click on the SQL Editor
Enter the following SQL:
<Tabs>
<Tab title="SQL">
```sql
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
details text,
completed bool DEFAULT false NOT NULL,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_todos_updated_at
BEFORE UPDATE ON public.todos
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
```
</Tab>
<Tab title="UI">
![Database SQL Editor](/images/tutorials/todos/1.png)
</Tab>
</Tabs>
<Warning>
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
</Warning>
</Step>
<Step>
### Set Up Permissions
Its now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
<Tabs>
<Tab title="Insert">
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
![Insert Permissions Configuration](/images/tutorials/todos/2.png)
</Tab>
<Tab title="Select">
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
![Select Permissions Configuration](/images/tutorials/todos/3.png)
</Tab>
<Tab title="Update">
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
![Update Permissions Configuration](/images/tutorials/todos/4.png)
</Tab>
<Tab title="Delete">
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
![Delete Permissions Configuration](/images/tutorials/todos/5.png)
</Tab>
</Tabs>
</Step>
<Step>
### Create the Todos Page Component
Now let's implement the SvelteKit page component that uses the database we just configured.
```svelte src/routes/todos/+page.svelte lines
<script lang="ts">
import { goto } from "$app/navigation";
import { auth } from "$lib/nhost/auth";
// The interfaces below define the structure of our data
// They are not strictly necessary but help with type safety
// Represents a single todo item
interface Todo {
id: string;
title: string;
details: string | null;
completed: boolean;
created_at: string;
updated_at: string;
user_id: string;
}
// This matches the GraphQL response structure for fetching todos
// Can be used as a generic type on the request method
interface GetTodos {
todos: Todo[];
}
// This matches the GraphQL response structure for inserting a todo
// Can be used as a generic type on the request method
interface InsertTodo {
insert_todos_one: Todo | null;
}
// This matches the GraphQL response structure for updating a todo
// Can be used as a generic type on the request method
interface UpdateTodo {
update_todos_by_pk: Todo | null;
}
let todos = $state<Todo[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let newTodoTitle = $state("");
let newTodoDetails = $state("");
let editingTodo = $state<Todo | null>(null);
let showAddForm = $state(false);
let expandedTodos = $state<Set<string>>(new Set());
// Redirect if not authenticated
$effect(() => {
if (!$auth.isLoading && !$auth.isAuthenticated) {
void goto("/signin");
}
});
async function fetchTodos() {
try {
loading = true;
// Make GraphQL request to fetch todos using Nhost client
// The query automatically filters by user_id due to Hasura permissions
const response = await $auth.nhost.graphql.request<GetTodos>({
query: `
query GetTodos {
todos(order_by: { created_at: desc }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
});
// Check for GraphQL errors in the response body
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch todos",
);
}
// Extract todos from the GraphQL response data
todos = response.body?.data?.todos || [];
error = null;
} catch (err) {
error = err instanceof Error ? err.message : "Failed to fetch todos";
} finally {
loading = false;
}
}
async function addTodo(e: SubmitEvent) {
e.preventDefault();
if (!newTodoTitle.trim()) return;
try {
// Execute GraphQL mutation to insert a new todo
// user_id is automatically set by Hasura based on JWT token
const response = await $auth.nhost.graphql.request<InsertTodo>({
query: `
mutation InsertTodo($title: String!, $details: String) {
insert_todos_one(object: { title: $title, details: $details }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
title: newTodoTitle.trim(),
details: newTodoDetails.trim() || null,
},
});
if (response.body.errors) {
throw new Error(response.body.errors[0]?.message || "Failed to add todo");
}
if (!response.body?.data?.insert_todos_one) {
throw new Error("Failed to add todo");
}
todos = [response.body?.data?.insert_todos_one, ...todos];
newTodoTitle = "";
newTodoDetails = "";
showAddForm = false;
error = null;
} catch (err) {
error = err instanceof Error ? err.message : "Failed to add todo";
}
}
async function updateTodo(
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
) {
try {
// Execute GraphQL mutation to update an existing todo by primary key
// Hasura permissions ensure users can only update their own todos
const response = await $auth.nhost.graphql.request<UpdateTodo>({
query: `
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
id,
updates,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to update todo",
);
}
if (!response.body?.data?.update_todos_by_pk) {
throw new Error("Failed to update todo");
}
const updatedTodo = response.body?.data?.update_todos_by_pk;
if (updatedTodo) {
todos = todos.map((todo) => (todo.id === id ? updatedTodo : todo));
}
editingTodo = null;
error = null;
} catch (err) {
error = err instanceof Error ? err.message : "Failed to update todo";
}
}
async function deleteTodo(id: string) {
if (!confirm("Are you sure you want to delete this todo?")) return;
try {
// Execute GraphQL mutation to delete a todo by primary key
// Hasura permissions ensure users can only delete their own todos
const response = await $auth.nhost.graphql.request({
query: `
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
variables: {
id,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to delete todo",
);
}
todos = todos.filter((todo) => todo.id !== id);
error = null;
} catch (err) {
error = err instanceof Error ? err.message : "Failed to delete todo";
}
}
async function toggleComplete(todo: Todo) {
await updateTodo(todo.id, { completed: !todo.completed });
}
async function saveEdit() {
if (!editingTodo) return;
await updateTodo(editingTodo.id, {
title: editingTodo.title,
details: editingTodo.details,
});
}
function toggleTodoExpansion(todoId: string) {
const newExpanded = new Set(expandedTodos);
if (newExpanded.has(todoId)) {
newExpanded.delete(todoId);
} else {
newExpanded.add(todoId);
}
expandedTodos = newExpanded;
}
// Fetch todos when user session is available
// The session contains the JWT token needed for GraphQL authentication
$effect(() => {
if ($auth.session) {
fetchTodos();
}
});
</script>
{#if !$auth.session}
<div class="auth-message">
<p>Please sign in to view your todos.</p>
</div>
{:else}
<div class="container">
<header class="page-header">
<h1 class="page-title">
My Todos
{#if !showAddForm}
<button
type="button"
onclick={() => (showAddForm = true)}
class="add-todo-btn"
title="Add a new todo"
>
+
</button>
{/if}
</h1>
</header>
{#if error}
<div class="error-message">
<strong>Error:</strong> {error}
</div>
{/if}
{#if showAddForm}
<div class="todo-form-card">
<form onsubmit={addTodo} class="todo-form">
<h2 class="form-title">Add New Todo</h2>
<div class="form-fields">
<div class="field-group">
<label for="title">Title *</label>
<input
id="title"
type="text"
bind:value={newTodoTitle}
placeholder="What needs to be done?"
required
/>
</div>
<div class="field-group">
<label for="details">Details</label>
<textarea
id="details"
bind:value={newTodoDetails}
placeholder="Add some details (optional)..."
rows="3"
></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Add Todo
</button>
<button
type="button"
onclick={() => {
showAddForm = false;
newTodoTitle = "";
newTodoDetails = "";
}}
class="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</form>
</div>
{/if}
{#if !showAddForm}
{#if loading}
<div class="loading-container">
<div class="loading-content">
<div class="spinner"></div>
<span class="loading-text">Loading todos...</span>
</div>
</div>
{:else}
<div class="todos-list">
{#if todos.length === 0}
<div class="empty-state">
<svg
class="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 class="empty-title">No todos yet</h3>
<p class="empty-description">
Create your first todo to get started!
</p>
</div>
{:else}
{#each todos as todo (todo.id)}
<div class="todo-card {todo.completed ? 'completed' : ''}">
{#if editingTodo?.id === todo.id}
<div class="todo-edit">
<div class="edit-fields">
<div class="field-group">
<label for="edit-title">Title</label>
<input
id="edit-title"
type="text"
bind:value={editingTodo.title}
/>
</div>
<div class="field-group">
<label for="edit-details">Details</label>
<textarea
id="edit-details"
bind:value={editingTodo.details}
rows="3"
></textarea>
</div>
<div class="edit-actions">
<button
type="button"
onclick={saveEdit}
class="btn btn-primary"
>
✓ Save Changes
</button>
<button
type="button"
onclick={() => (editingTodo = null)}
class="btn btn-cancel"
>
✕ Cancel
</button>
</div>
</div>
</div>
{:else}
<div class="todo-content">
<div class="todo-header">
<button
type="button"
class="todo-title-btn {todo.completed ? 'completed' : ''}"
onclick={() => toggleTodoExpansion(todo.id)}
>
{todo.title}
</button>
<div class="todo-actions">
<button
type="button"
onclick={() => toggleComplete(todo)}
class="action-btn action-btn-complete"
title={todo.completed
? "Mark as incomplete"
: "Mark as complete"}
>
{todo.completed ? "↶" : "✓"}
</button>
<button
type="button"
onclick={() => (editingTodo = todo)}
class="action-btn action-btn-edit"
title="Edit todo"
>
✏️
</button>
<button
type="button"
onclick={() => deleteTodo(todo.id)}
class="action-btn action-btn-delete"
title="Delete todo"
>
🗑️
</button>
</div>
</div>
{#if expandedTodos.has(todo.id)}
<div class="todo-details">
{#if todo.details}
<div class="todo-description {todo.completed ? 'completed' : ''}">
<p>{todo.details}</p>
</div>
{/if}
<div class="todo-meta">
<div class="meta-dates">
<span class="meta-item">
Created: {new Date(todo.created_at).toLocaleString()}
</span>
<span class="meta-item">
Updated: {new Date(todo.updated_at).toLocaleString()}
</span>
</div>
{#if todo.completed}
<div class="completion-badge">
<svg
class="completion-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Completed</span>
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
{/if}
</div>
{/if}
```
</Step>
<Step>
### Update Navigation Links
Add a link to the todos page in the navigation layout. Update your `src/routes/+layout.svelte` file to include the todos link:
```svelte src/routes/+layout.svelte lines highlight={39}
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { auth, initializeAuth, nhost } from "$lib/nhost/auth";
import "../app.css";
let { children }: { children?: import("svelte").Snippet } = $props();
// Initialize auth when component mounts
onMount(() => {
return initializeAuth();
});
// Helper function to determine if a link is active
function isActive(path: string): string {
return $page.url.pathname === path ? "nav-link active" : "nav-link";
}
async function handleSignOut() {
if ($auth.session) {
await nhost.auth.signOut({
refreshToken: $auth.session.refreshToken,
});
void goto("/");
}
}
</script>
<div id="root">
<nav class="navigation">
<div class="nav-container">
<a href="/" class="nav-logo">Nhost SvelteKit Demo</a>
<div class="nav-links">
<a href="/" class="nav-link">Home</a>
{#if $auth.isAuthenticated}
<a href="/todos" class={isActive('/todos')}>Todos</a>
<a href="/profile" class={isActive('/profile')}>Profile</a>
<button
onclick={handleSignOut}
class="nav-link nav-button"
>
Sign Out
</button>
{:else}
<a href="/signin" class="nav-link {isActive('/signin')}">
Sign In
</a>
<a href="/signup" class="nav-link {isActive('/signup')}">
Sign Up
</a>
{/if}
</div>
</div>
</nav>
<div class="app-content">
{#if children}
{@render children()}
{/if}
</div>
</div>
```
</Step>
<Step>
### Test Your Complete Application
Run your SvelteKit application and test all the functionality:
```bash
npm run dev
```
Things to try out:
1. Try signing in and out and see how the Todos page is only available when authenticated
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly
3. Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Database Schema" icon="database">
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
</Accordion>
<Accordion title="GraphQL API" icon="webhook">
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
</Accordion>
<Accordion title="Row-Level Security" icon="shield-check">
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
</Accordion>
<Accordion title="CRUD Operations" icon="arrows-rotate">
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
</Accordion>
<Accordion title="Rich Interface" icon="sparkles">
Expandable todo items, inline editing, completion status, and detailed timestamps using SvelteKit's reactive UI patterns.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,642 @@
---
title: File Uploads in SvelteKit
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and SvelteKit
sidebarTitle: "File Uploads"
icon: upload
---
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a SvelteKit application.
<Info>
This is **Part 5** in the Full-Stack SvelteKit Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
</Info>
## Full-Stack SvelteKit Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/svelte/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/svelte/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/svelte/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/svelte/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/svelte/5-file-uploads">
**Current** - File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [GraphQL Operations part](/getting-started/tutorials/svelte/4-graphql-operations) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- A **personal bucket** so users can upload their own private files
- **File upload functionality**
- **File management interface** for viewing and deleting files
- **Security permissions** ensuring users can only access their own files
## Step-by-Step Guide
<Steps>
<Step>
### Create a Personal Storage Bucket
First, we'll create a storage bucket where users can upload their personal files.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Change to **schema.storage**, then buckets
3. Now click on `+ Insert` on the top right corner.
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
![Create bucket](/images/tutorials/uploads/1.png)
</Step>
<Step>
### Configure Storage Permissions
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
<Tabs>
<Tab title="Upload">
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
![upload files permissions](/images/tutorials/uploads/2.png)
</Tab>
<Tab title="Download">
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
![download files permissions](/images/tutorials/uploads/3.png)
</Tab>
<Tab title="Delete">
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
![delete files permissions](/images/tutorials/uploads/4.png)
</Tab>
</Tabs>
<Info>
You can read more about storage permissions [here](/products/storage/overview#permissions)
</Info>
</Step>
<Step>
### Create the File Upload Component
Now let's implement the SvelteKit page component for file upload functionality.
```svelte src/routes/files/+page.svelte lines
<script lang="ts">
import type { FileMetadata } from "@nhost/nhost-js/storage";
import { goto } from "$app/navigation";
import { auth } from "$lib/nhost/auth";
interface DeleteStatus {
message: string;
isError: boolean;
}
interface GraphqlGetFilesResponse {
files: FileMetadata[];
}
let fileInputRef = $state<HTMLInputElement>();
let selectedFile = $state<File | null>(null);
let uploading = $state(false);
let uploadResult = $state<FileMetadata | null>(null);
let isFetching = $state(true);
let error = $state<string | null>(null);
let files = $state<FileMetadata[]>([]);
let viewingFile = $state<string | null>(null);
let deleting = $state<string | null>(null);
let deleteStatus = $state<DeleteStatus | null>(null);
// Redirect if not authenticated
$effect(() => {
if (!$auth.isLoading && !$auth.isAuthenticated) {
void goto("/signin");
}
});
// Format file size in a readable way
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
}
async function fetchFiles() {
isFetching = true;
error = null;
try {
// Use GraphQL to fetch files from the storage system
// Files are automatically filtered by user permissions
const response = await $auth.nhost.graphql.request<GraphqlGetFilesResponse>(
{
query: `query GetFiles {
files {
id
name
size
mimeType
bucketId
uploadedByUserId
}
}`,
},
);
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch files",
);
}
files = response.body.data?.files || [];
} catch (err) {
console.error("Error fetching files:", err);
error = "Failed to load files. Please try refreshing the page.";
} finally {
isFetching = false;
}
}
// Fetch files when user session is available
$effect(() => {
if ($auth.session) {
fetchFiles();
}
});
function handleFileChange(e: Event) {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
const file = target.files[0];
if (file) {
selectedFile = file;
error = null;
uploadResult = null;
}
}
}
async function handleUpload() {
if (!selectedFile) {
error = "Please select a file to upload";
return;
}
uploading = true;
error = null;
try {
// Upload file to the personal bucket
// The uploadedByUserId is automatically set by the storage permissions
const response = await $auth.nhost.storage.uploadFiles({
"bucket-id": "personal",
"file[]": [selectedFile],
});
const uploadedFile = response.body.processedFiles?.[0];
if (uploadedFile === undefined) {
throw new Error("Failed to upload file");
}
uploadResult = uploadedFile;
// Clear the form
selectedFile = null;
if (fileInputRef) {
fileInputRef.value = "";
}
// Update the files list
files = [uploadedFile, ...files];
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
uploadResult = null;
}, 3000);
} catch (err: unknown) {
const message = (err as Error).message || "An unknown error occurred";
error = `Failed to upload file: ${message}`;
} finally {
uploading = false;
}
}
async function handleViewFile(
fileId: string,
fileName: string,
mimeType: string,
) {
viewingFile = fileId;
try {
// Get the file from storage
const response = await $auth.nhost.storage.getFile(fileId);
const url = URL.createObjectURL(response.body);
// Handle different file types appropriately
if (
mimeType.startsWith("image/") ||
mimeType === "application/pdf" ||
mimeType.startsWith("text/") ||
mimeType.startsWith("video/") ||
mimeType.startsWith("audio/")
) {
// Open viewable files in new tab
window.open(url, "_blank");
} else {
// Download other file types
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Show download confirmation
const newWindow = window.open("", "_blank", "width=400,height=200");
if (newWindow) {
newWindow.document.documentElement.innerHTML = `
<head>
<title>File Download</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
</style>
</head>
<body>
<h3>Downloading: ${fileName}</h3>
<p>Your download has started. You can close this window.</p>
</body>
`;
}
}
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
error = `Failed to view file: ${message}`;
console.error("Error viewing file:", err);
} finally {
viewingFile = null;
}
}
async function handleDeleteFile(fileId: string) {
if (!fileId || deleting) return;
deleting = fileId;
error = null;
deleteStatus = null;
const fileToDelete = files.find((file) => file.id === fileId);
const fileName = fileToDelete?.name || "File";
try {
// Delete file from storage
// Permissions ensure users can only delete their own files
await $auth.nhost.storage.deleteFile(fileId);
deleteStatus = {
message: `${fileName} deleted successfully`,
isError: false,
};
// Remove from local state
files = files.filter((file) => file.id !== fileId);
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
deleteStatus = null;
}, 3000);
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
deleteStatus = {
message: `Failed to delete ${fileName}: ${message}`,
isError: true,
};
console.error("Error deleting file:", err);
} finally {
deleting = null;
}
}
</script>
{#if !$auth.session}
<div class="auth-message">
<p>Please sign in to access file uploads.</p>
</div>
{:else}
<div class="container">
<header class="page-header">
<h1 class="page-title">File Upload</h1>
</header>
<div class="form-card">
<h2 class="form-title">Upload a File</h2>
<div class="field-group">
<input
type="file"
bind:this={fileInputRef}
onchange={handleFileChange}
style="position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0;"
aria-hidden="true"
tabindex="-1"
/>
<button
type="button"
class="btn btn-secondary file-upload-btn"
onclick={() => fileInputRef?.click()}
>
<svg
width="40"
height="40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="Upload file"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p>Click to select a file</p>
{#if selectedFile}
<p class="file-upload-info">
{selectedFile.name} ({formatFileSize(selectedFile.size)})
</p>
{/if}
</button>
</div>
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if uploadResult}
<div class="success-message">File uploaded successfully!</div>
{/if}
<button
type="button"
onclick={handleUpload}
disabled={!selectedFile || uploading}
class="btn btn-primary"
style="width: 100%"
>
{uploading ? "Uploading..." : "Upload File"}
</button>
</div>
<div class="form-card">
<h2 class="form-title">Your Files</h2>
{#if deleteStatus}
<div class={deleteStatus.isError ? "error-message" : "success-message"}>
{deleteStatus.message}
</div>
{/if}
{#if isFetching}
<div class="loading-container">
<div class="loading-content">
<div class="spinner"></div>
<span class="loading-text">Loading files...</span>
</div>
</div>
{:else if files.length === 0}
<div class="empty-state">
<svg
class="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<h3 class="empty-title">No files yet</h3>
<p class="empty-description">Upload your first file to get started!</p>
</div>
{:else}
<div style="overflow-x: auto">
<table class="file-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#each files as file (file.id)}
<tr>
<td class="file-name">{file.name}</td>
<td class="file-meta">{file.mimeType}</td>
<td class="file-meta">{formatFileSize(file.size || 0)}</td>
<td>
<div class="file-actions">
<button
type="button"
onclick={() =>
handleViewFile(
file.id || "unknown",
file.name || "unknown",
file.mimeType || "unknown",
)}
disabled={viewingFile === file.id}
class="action-btn action-btn-edit"
title="View File"
>
{viewingFile === file.id ? "⏳" : "👁️"}
</button>
<button
type="button"
onclick={() => handleDeleteFile(file.id || "unknown")}
disabled={deleting === file.id}
class="action-btn action-btn-delete"
title="Delete File"
>
{deleting === file.id ? "⏳" : "🗑️"}
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{/if}
```
</Step>
<Step>
### Update Navigation Links
Add a link to the files page in the navigation layout. Update your `src/routes/+layout.svelte` file to include the files link:
```svelte src/routes/+layout.svelte lines highlight={40}
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { auth, initializeAuth, nhost } from "$lib/nhost/auth";
import "../app.css";
let { children }: { children?: import("svelte").Snippet } = $props();
// Initialize auth when component mounts
onMount(() => {
return initializeAuth();
});
// Helper function to determine if a link is active
function isActive(path: string): string {
return $page.url.pathname === path ? "nav-link active" : "nav-link";
}
async function handleSignOut() {
if ($auth.session) {
await nhost.auth.signOut({
refreshToken: $auth.session.refreshToken,
});
void goto("/");
}
}
</script>
<div id="root">
<nav class="navigation">
<div class="nav-container">
<a href="/" class="nav-logo">Nhost SvelteKit Demo</a>
<div class="nav-links">
<a href="/" class="nav-link">Home</a>
{#if $auth.isAuthenticated}
<a href="/todos" class={isActive('/todos')}>Todos</a>
<a href="/files" class={isActive('/files')}>Files</a>
<a href="/profile" class={isActive('/profile')}>Profile</a>
<button
onclick={handleSignOut}
class="nav-link nav-button"
>
Sign Out
</button>
{:else}
<a href="/signin" class="nav-link {isActive('/signin')}">
Sign In
</a>
<a href="/signup" class="nav-link {isActive('/signup')}">
Sign Up
</a>
{/if}
</div>
</div>
</nav>
<div class="app-content">
{#if children}
{@render children()}
{/if}
</div>
</div>
```
</Step>
<Step>
### Test Your File Upload System
Run your SvelteKit application and test all the functionality:
```bash
npm run dev
```
Things to try out:
1. Try signing in and out and see how the file upload page is only accessible when signed in.
2. Upload different types of files (images, documents, etc.)
3. View and delete files
4. Sign in with another account and verify you cannot see files from the first account
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Storage Bucket" icon="bucket">
Dedicated personal storage bucket with proper configuration for user file isolation.
</Accordion>
<Accordion title="File Upload Interface" icon="upload">
User-friendly upload interface with file selection, preview, and progress feedback using SvelteKit's reactive patterns.
</Accordion>
<Accordion title="File Management" icon="folder">
Complete file listing with metadata, viewing capabilities, and deletion functionality.
</Accordion>
<Accordion title="File Type Handling" icon="file">
Intelligent handling of different file types with appropriate viewing/download behavior.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling with user-friendly messages for upload and management operations using Svelte stores.
</Accordion>
</AccordionGroup>

View File

@@ -1,497 +0,0 @@
---
title: Build a Todo Manager with SvelteKit
description: Learn how to use Nhost with SvelteKit
sidebarTitle: SvelteKit
icon: S
---
In this tutorial, you will build a simple **Todo Manager** application with Nhost and React. Along the way you will interact with the Database, Authentication, and Storage services.
The Todo Manager will allow users to see public `todos` and sign in using a Magic Link to manage their own `todos` with attachments.
<CardGroup cols={3}>
<Card title="Database">
To store todos
</Card>
<Card title="Auth">
To sign in users
</Card>
<Card title="Storage">
To store attachments
</Card>
</CardGroup>
## Setup Nhost Backend
In this section, you will create and setup your first Nhost project.
### Create project
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
- Dedicated PostgreSQL
- Realtime APIs over your data
- Authentication for managing your users
- Storage for handling files
### Create table `todos`
On the project's dashboard, navigate to **Database** and create a new table called `todos`.
![Database](/images/tutorials/todos-react-database.png)
You can either copy and paste the following SQL into the SQL Editor, **Database -> SQL Editor**, or manually create the table by clicking on **New Table**.
<Tabs>
<Tab title="SQL Editor">
Copy and paste the following SQL into the SQL Editor and press **Run**.
<Note>Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs</Note>
```sql SQL
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
completed bool DEFAULT 'false' NOT NULL,
file_id uuid,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (file_id) REFERENCES storage.files (id) ON UPDATE SET NULL ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE SET NULL ON DELETE SET NULL
);
```
</Tab>
<Tab title="UI">
Click on **New Table** and fill in the details for the `todos` table as shown.
![New Table](/images/tutorials/todos-react-database-new-table.png)
</Tab>
</Tabs>
You should now see a new table called `todos` on the left panel, below **New Table**.
### Set permissions for todos
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **...**, followed by **Edit Permissions**.
You will set permissions for the `user` role and actions `insert`, `select`, `update`, and `delete`.
<Tabs>
<Tab title="insert">
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
![User Insert](/images/tutorials/todos-react-permissions-insert.png)
</Tab>
<Tab title="select">
Click on the right cell for the `user` role and action `select` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-select.png)
</Tab>
<Tab title="update">
Click on the right cell for the `user` role and action `update` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-update.png)
</Tab>
<Tab title="delete">
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
![User Delete](/images/tutorials/todos-react-permissions-delete.png)
</Tab>
</Tabs>
### Set permissions for files
The `files` table is managed by Nhost and is defined on the `storage` schema. Click on the dropdown right next to `schema.public` and choose `schema.storage`.
With the `files` table selected, click on **...**, followed by **Edit Permissions**.
As before, we want to set permissions for the `user` role and actions `insert`, `select`, `delete`.
<Tabs>
<Tab title="insert">
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
![User Insert](/images/tutorials/todos-react-permissions-files-insert.png)
</Tab>
<Tab title="select">
Click on the right cell for the `user` role and action `select` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-files-select.png)
</Tab>
<Tab title="delete">
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
![User Delete](/images/tutorials/todos-react-permissions-files-delete.png)
</Tab>
</Tabs>
### Enable Sign In with Magic Link
To enable Magic Links, navigate to your project's **Settings -> Sign-In Methods**, toggle Magic Link, and save.
### Recap
<Steps>
<Step title="Nhost project created">
</Step>
<Step title="Database todos created">
</Step>
<Step title="Permissions set for todos and files">
</Step>
<Step title="Magic Link enabled">
</Step>
</Steps>
## Setup React Application
Now that we have Nhost configured, let's move on to setup the React application and the Nhost client.
### Create React Application
Run the following command in your terminal to create a React application using Vite.
```bash Terminal
npm create vite@latest nhost-react -- --template react
```
### Install Nhost React package
To install Nhost's React package, run the following command.
```bash Terminal
cd nhost-react && npm install @nhost/react
```
#### Configure the Nhost Client
Create a new file, `./src/lib/nhost.js`, with the following code to create a Nhost client. Replace `<SUBDOMAIN>` and `<REGION>` with the values from the project created earlier.
```ts ./src/lib/nhost.ts
import { NhostClient } from "@nhost/react";
export const nhost = new NhostClient({
subdomain: "<SUBDOMAIN>",
region: "<REGION>"
});
```
<Info>The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project Info**</Info>
### Setup Sign In Component
It is time to setup a new React component to handle the login functionality. Users will be able to sign in using a Magic Link.
Create a new file `./src/signin.jsx` with the following content:
```js ./src/signin.jsx
import { useState } from 'react'
import { useSignInEmailPasswordless } from '@nhost/react'
export default function SignIn() {
const [loading, setLoading] = useState(false)
const [email, setEmail] = useState('')
const { signInEmailPasswordless, error } = useSignInEmailPasswordless()
const handleSignIn = async (event) => {
event.preventDefault()
setLoading(true)
const { error } = await signInEmailPasswordless(email)
if (error) {
console.error({ error })
return
}
setLoading(false)
alert('Magic Link Sent!')
}
return (
<div>
<h1>Todo Manager</h1>
<p>powered by Nhost and React</p>
<form onSubmit={handleSignIn}>
<div>
<input
type="email"
placeholder="Your email"
value={email}
required={true}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<button disabled={loading}>
{loading ? <span>Loading</span> : <span>Send me a Magic Link!</span>}
</button>
</div>
{error && <p>{error.message}</p>}
</form>
</div>
)
}
```
### Setup `Todos` Component
Now that users can sign in, let's move on and create the authenticated page that lists a user's todos and has a form for managing todos with attachments.
```js ./src/todos.jsx
import { useState, useEffect } from 'react'
import { useNhostClient, useFileUpload } from '@nhost/react'
const deleteTodo = `
mutation($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`
const createTodo = `
mutation($title: String!, $file_id: uuid) {
insert_todos_one(object: {title: $title, file_id: $file_id}) {
id
}
}
`
const getTodos = `
query {
todos {
id
title
file_id
completed
}
}
`
export default function Todos() {
const [loading, setLoading] = useState(true)
const [todos, setTodos] = useState([])
const [todoTitle, setTodoTitle] = useState('')
const [todoAttachment, setTodoAttachment] = useState(null)
const [fetchAll, setFetchAll] = useState(false)
const nhostClient = useNhostClient()
const { upload } = useFileUpload()
useEffect(() => {
async function fetchTodos() {
setLoading(true)
const { data, error } = await nhostClient.graphql.request(getTodos)
if (error) {
console.error({ error })
return
}
setTodos(data.todos)
setLoading(false)
}
fetchTodos()
return () => {
setFetchAll(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchAll])
const handleCreateTodo = async (e) => {
e.preventDefault()
let todo = { title: todoTitle }
if (todoAttachment) {
const { id, error } = await upload({
file: todoAttachment,
name: todoAttachment.name
})
if (error) {
console.error({ error })
return
}
todo.file_id = id
}
const { error } = await nhostClient.graphql.request(createTodo, todo)
if (error) {
console.error({ error })
}
setTodoTitle('')
setTodoAttachment(null)
setFetchAll(true)
}
const handleDeleteTodo = async (id) => {
if (!window.confirm('Are you sure you want to delete this TODO?')) {
return
}
const todo = todos.find((todo) => todo.id === id)
if (todo.file_id) {
await nhostClient.storage.delete({ fileId: todo.file_id })
}
const { error } = await nhostClient.graphql.request(deleteTodo, { id })
if (error) {
console.error({ error })
}
setFetchAll(true)
}
const completeTodo = async (id) => {
const { error } = await nhostClient.graphql.request(
`
mutation($id: uuid!) {
update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
completed
}
}
`,
{ id }
)
if (error) {
console.error({ error })
}
setFetchAll(true)
}
const openAttachment = async (todo) => {
const { presignedUrl, error } = await nhostClient.storage.getPresignedUrl({
fileId: todo.file_id
})
if (error) {
console.error({ error })
return
}
window.open(presignedUrl.url, '_blank')
}
return (
<>
<div className="container">
<div className="form-section">
<h2>Add a new TODO</h2>
<form onSubmit={handleCreateTodo}>
<div className="input-group">
<label htmlFor="title">Title</label>
<input
id="title"
type="text"
placeholder="Title"
value={todoTitle}
onChange={(e) => setTodoTitle(e.target.value)}
/>
</div>
<div className="input-group">
<label htmlFor="file">File (optional)</label>
<input id="file" type="file" onChange={(e) => setTodoAttachment(e.target.files[0])} />
</div>
<div className="submit-group">
<button type="submit" disabled={!todoTitle}>
Add Todo
</button>
</div>
</form>
</div>
<div className="todos-section">
{(!loading &&
todos.map((todo) => (
<div className="todo-item" key={todo.id ?? 0}>
<input
type="checkbox"
checked={todo.completed}
disabled={todo.completed}
id={`todo-${todo.id}`}
onChange={() => completeTodo(todo.id)}
/>
{todo.file_id && (
<span>
<a onClick={() => openAttachment(todo)}> Open Attachment</a>
</span>
)}
<label htmlFor={`todo-${todo.id}`} className="todo-title">
{todo.completed && <s>{todo.title}</s>}
{!todo.completed && todo.title}
</label>
<button type="button" onClick={() => handleDeleteTodo(todo.id)}>
Delete
</button>
</div>
))) || (
<div className="todo-item">
<label className="todo-title">Loading...</label>
</div>
)}
</div>
</div>
<div className="sign-out-section">
<button type="button" onClick={() => nhostClient.auth.signOut()}>
Sign Out
</button>
</div>
</>
)
}
```
With both `SignIn` and `Todos` in place, update `./src/App.jsx` to use the new components:
```js ./src/App.jsx
import './App.css'
import { NhostProvider } from '@nhost/react'
import { nhost } from './lib/nhost.js'
import SignIn from './signin'
import Todos from './todos'
import { useEffect, useState } from 'react'
function App() {
const [session, setSession] = useState(null)
useEffect(() => {
setSession(nhost.auth.getSession())
nhost.auth.onAuthStateChanged((_, session) => {
setSession(session)
})
}, [])
return (
<NhostProvider nhost={nhost}>
{session ? <Todos session={session} /> : <SignIn />}
</NhostProvider>
)
}
export default App
```
## The End
Run the Todo Manager with:
```bash Terminal
npm run dev -- --open --port 3000
```
Open your browser on [localhost:3000](localhost:3000) to see your new application in action.

View File

@@ -1,504 +0,0 @@
---
title: Build a Todo Manager with Vue
description: Learn how to use Nhost with Vue
sidebarTitle: Vue
icon: vuejs
---
In this tutorial, you will build a simple **Todo Manager** with Vue and Nhost. The Todo Manager will allow users to sign in using a Magic Link and manage their own Todos with attachments.
<CardGroup cols={3}>
<Card title="Database">
To store todos
</Card>
<Card title="Auth">
To sign in users
</Card>
<Card title="Storage">
To store attachments
</Card>
</CardGroup>
## Setup Nhost Backend
In this section, you will create and setup your first Nhost project.
### Create project
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
- Dedicated PostgreSQL
- Realtime APIs over your data
- Authentication for managing your users
- Storage for handling files
### Create table todos
On the project's dashboard, navigate to **Database** and create a new table called `todos`.
![Database](/images/tutorials/todos-react-database.png)
You can either copy and paste the following SQL into the SQL Editor, **Database -> SQL Editor**, or manually create the table by clicking on **New Table**.
<Tabs>
<Tab title="SQL Editor">
Copy and paste the following SQL into the SQL Editor and press **Run**.
<Note>Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs</Note>
```sql SQL
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
completed bool DEFAULT 'false' NOT NULL,
file_id uuid,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (file_id) REFERENCES storage.files (id) ON UPDATE SET NULL ON DELETE SET NULL,
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE SET NULL ON DELETE SET NULL
);
```
</Tab>
<Tab title="UI">
Click on **New Table** and fill in the details for the `todos` table as shown.
![New Table](/images/tutorials/todos-react-database-new-table.png)
</Tab>
</Tabs>
You should now see a new table called `todos` on the left panel, above **New Table**.
### Set permissions for todos
It's now time to set permission rules for the table you just created. With the table `todos` selected, click on **...**, followed by **Edit Permissions**.
You will set permissions for the `user` role and actions `insert`, `select`, `update`, and `delete`.
<Tabs>
<Tab title="insert">
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
![User Insert](/images/tutorials/todos-react-permissions-insert.png)
</Tab>
<Tab title="select">
Click on the right cell for the `user` role and action `select` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-select.png)
</Tab>
<Tab title="update">
Click on the right cell for the `user` role and action `update` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-update.png)
</Tab>
<Tab title="delete">
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
![User Delete](/images/tutorials/todos-react-permissions-delete.png)
</Tab>
</Tabs>
### Set permissions for files
The `files` table is managed by Nhost and is defined on the `storage` schema. Click on the dropdown right next to `schema.public` and choose `schema.storage`.
With the `files` table selected, click on **...**, followed by **Edit Permissions**.
As before, we want to set permissions for the `user` role and actions `insert`, `select`, `delete`.
<Tabs>
<Tab title="insert">
Click on the right cell for the `user` role and action `insert` and set permissions as follows:
![User Insert](/images/tutorials/todos-react-permissions-files-insert.png)
</Tab>
<Tab title="select">
Click on the right cell for the `user` role and action `select` and set permissions as follows:
![User Select](/images/tutorials/todos-react-permissions-files-select.png)
</Tab>
<Tab title="delete">
Click on the right cell for the `user` role and action `delete` and set permissions as follows:
![User Delete](/images/tutorials/todos-react-permissions-files-delete.png)
</Tab>
</Tabs>
### Enable Sign In with Magic Link
To enable Magic Links, navigate to your project's **Settings -> Sign-In Methods**, toggle Magic Link, and save.
### Recap
<Steps>
<Step title="Nhost project created">
</Step>
<Step title="Database todos created">
</Step>
<Step title="Permissions set for todos and files">
</Step>
<Step title="Magic Link enabled">
</Step>
</Steps>
## Setup Vue Application
Now that we have Nhost configured, let's move on to setup the Vue application and the Nhost client.
### Create Vue Application
Run the following command in your terminal to create a Vue application using Vite.
```bash Terminal
npm create vue@latest nhost-vue
```
### Install Nhost Vue package
To install Nhost's Vue package, run the following command.
```bash Terminal
cd nhost-vue && npm install @nhost/vue
```
#### Configure the Nhost Client
Create a new file `./src/lib/nhost.js` with the following code to create a Nhost client. Replace `<subdomain>` and `<region>` with the values for the project you created earlier.
```js ./src/lib/nhost.js
import { NhostClient } from "@nhost/vue";
export const nhost = new NhostClient({
subdomain: "<SUBDOMAIN>",
region: "<REGION>"
});
```
<Info>The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project Info**</Info>
### Setup Sign In Component
It is time to setup a new React component to handle the login functionality. Your users will be able to sign in using a Magic Link and without a password.
Create a new file `./src/SignIn.vue` for the Sign In component with the following content:
```js ./src/SignIn.vue
<template>
<div>
<h1>Todo Manager</h1>
<p>powered by Nhost and Vue</p>
<form @submit.prevent="handleSignIn">
<div>
<input type="email" placeholder="Your email" v-model="email" required />
</div>
<div>
<button :disabled="loading">
<span v-if="loading">Loading</span>
<span v-else>Send me a Magic Link!</span>
</button>
</div>
<p v-if="error">{{ error.message }}</p>
</form>
</div>
</template>
<script>
import { ref } from "vue";
import { useSignInEmailPasswordless } from "@nhost/vue";
export default {
setup() {
const email = ref("");
const { signInEmailPasswordless, error } = useSignInEmailPasswordless();
const loading = ref(false);
const handleSignIn = async () => {
loading.value = true;
const { error } = await signInEmailPasswordless(email.value);
if (error) {
console.error({ error });
loading.value = false;
return;
}
loading.value = false;
alert("Magic Link Sent!");
};
return { email, handleSignIn, loading, error };
},
};
</script>
```
### Setup Todos Component
Now that users can sign in, go ahead and create the authenticated page that lists a user's todos and has a form for managing todos with attachments.
```js ./src/Todos.vue
<template>
<div class="container">
<div class="form-section">
<h2>Add a new TODO</h2>
<form @submit.prevent="handleCreateTodo">
<div class="input-group">
<label for="title">Title</label>
<input
id="title"
type="text"
placeholder="Title"
v-model="todoTitle"
/>
</div>
<div class="input-group">
<label for="file">File (optional)</label>
<input
id="file"
type="file"
@change="handleFileChange"
/>
</div>
<div class="submit-group">
<button type="submit" :disabled="!todoTitle">
Add Todo
</button>
</div>
</form>
</div>
<div class="todos-section">
<div
class="todo-item"
v-for="todo in todos"
:key="todo.id"
>
<input
type="checkbox"
:checked="todo.completed"
:disabled="todo.completed"
:id="`todo-${todo.id}`"
@change="() => completeTodo(todo.id)"
/>
<span v-if="todo.file_id">
<a @click="() => openAttachment(todo)">Open Attachment</a>
</span>
<label :for="`todo-${todo.id}`" class="todo-title">
<s v-if="todo.completed">{{ todo.title }}</s>
<span v-else>{{ todo.title }}</span>
</label>
<button type="button" @click="() => handleDeleteTodo(todo.id)">
Delete
</button>
</div>
<div class="todo-item" v-if="loading">
<label class="todo-title">Loading...</label>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import { useNhostClient, useFileUpload } from '@nhost/vue';
const getTodos = `
query {
todos {
id
title
file_id
completed
}
}
`;
const createTodo = `
mutation($title: String!, $file_id: uuid) {
insert_todos_one(object: {title: $title, file_id: $file_id}) {
id
}
}
`;
const deleteTodo = `
mutation($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`;
export default {
setup() {
const { nhost } = useNhostClient();
const { upload } = useFileUpload();
const todos = ref([]);
const todoTitle = ref('');
const todoAttachment = ref(null);
const loading = ref(true);
const fetchTodos = async () => {
loading.value = true;
const { data, error } = await nhost.graphql.request(getTodos);
if (error) {
console.error({ error });
return;
}
todos.value = data.todos;
loading.value = false;
};
onMounted(fetchTodos);
const handleCreateTodo = async () => {
let todo = { title: todoTitle.value };
if (todoAttachment.value) {
const { id, error } = await upload({
file: todoAttachment.value,
name: todoAttachment.value.name
});
if (error) {
console.error({ error });
return;
}
todo.file_id = id;
}
const { error } = await nhost.graphql.request(createTodo, todo);
if (error) {
console.error({ error });
}
todoTitle.value = '';
todoAttachment.value = null;
fetchTodos();
};
const handleDeleteTodo = async (id) => {
if (!window.confirm('Are you sure you want to delete this TODO?')) {
return;
}
const todo = todos.value.find((t) => t.id === id);
if (todo && todo.file_id) {
await nhost.storage.delete({ fileId: todo.file_id });
}
const { error } = await nhost.graphql.request(deleteTodo, { id });
if (error) {
console.error({ error });
}
fetchTodos();
};
const completeTodo = async (id) => {
const { error } = await nhost.graphql.request(
`
mutation($id: uuid!) {
update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
completed
}
}
`,
{ id }
);
if (error) {
console.error({ error });
}
fetchTodos();
};
const openAttachment = async (todo) => {
const { presignedUrl, error } = await nhost.storage.getPresignedUrl({
fileId: todo.file_id
});
if (error) {
console.error({ error });
return;
}
window.open(presignedUrl.url, '_blank');
};
return {
todos,
todoTitle,
todoAttachment,
loading,
handleCreateTodo,
handleDeleteTodo,
completeTodo,
openAttachment
};
},
};
</script>
```
With both `SignIn` and `Todos` in place, update `./src/App.vue` to use the new components:
```js ./src/App.vue
<template>
<Todos v-if="session" :session="session" />
<SignIn v-else />
</template>
<script>
import { ref, onMounted } from 'vue';
import SignIn from './SignIn.vue';
import Todos from './Todos.vue';
import { useNhostClient } from '@nhost/vue';
export default {
components: { SignIn, Todos },
setup() {
const session = ref(null);
const { nhost } = useNhostClient()
onMounted(() => {
session.value = nhost.auth.getSession();
nhost.auth.onAuthStateChanged((_, newSession) => {
session.value = newSession;
});
});
return { session };
},
};
</script>
```
The last step missing is to install `nhost` as a plugin:
```js ./src/main.js
import "./assets/main.css";
import { nhost } from "./lib/nhost";
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).use(nhost).mount("#app");
```
## The End
Run the Todo Manager with:
```bash Terminal
npm run dev -- --open --port 3000
```
Open your browser on [localhost:3000](localhost:3000) to see your new application in action.

View File

@@ -0,0 +1,116 @@
---
title: Create Your Nhost Project
description: Learn how to create and set up a new Nhost project to get started building your Vue application
sidebarTitle: Create Project
icon: plus
---
Welcome to the **Full-Stack Vue Development with Nhost** series! In this comprehensive tutorial series, you'll build a complete Vue application with Nhost that demonstrates authentication, database operations, and file management.
## About This Tutorial Series
This tutorial series is divided into **5 parts**, each focusing on a specific aspect of building modern web applications with Nhost and Vue. By the end of the series, you'll have built a fully functional application featuring:
- **User Authentication** - Complete sign up, sign in, and email verification flow
- **Todo Management** - Users can create, update, delete, and mark todos as complete
- **File Uploads** - Users can upload and manage files with proper permissions
- **Protected Routes** - Secure areas that only authenticated users can access
<Info>
This is **Part 1** in the Full-Stack Vue Development with Nhost series. This part sets up the foundation by creating your Nhost project and understanding the series structure.
</Info>
## Full-Stack Vue Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
**Current** - Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## What You'll Learn
Throughout this series, you'll master:
- Setting up and configuring Nhost projects
- Implementing secure authentication flows
- Building protected routes with Vue Router
- Performing GraphQL queries and mutations
- Managing file uploads and storage
- Configuring database permissions and security
- Building responsive Vue interfaces
## Prerequisites
- Node.js 20+ installed on your machine
- Basic knowledge of Vue and JavaScript
- Understanding of modern web development concepts
Creating an Nhost project is the first step to building your application with Nhost. Let's get started by setting up your backend infrastructure.
## Step-by-Step Guide
<Steps>
<Step>
### Sign Up or Log in
If you don't have an Nhost account, sign up at [Nhost](https://app.nhost.io/). If you already have an account, log in.
![sign up/sign in](/images/tutorials/create-nhost-project/1.png)
</Step>
<Step>
### Create a New Project
Click on the "Create Project" button on your dashboard or follow the onboarding prompts if you're a new user.
![2](/images/tutorials/create-nhost-project/2.png)
</Step>
<Step>
### Take note of your project subdomain and region
Take note of your project subdomain and region. You will need this information to connect your application to the Nhost backend in upcoming tutorials.
![3](/images/tutorials/create-nhost-project/3.png)
</Step>
</Steps>
## What's Next?
With your Nhost project created, you now have access to:
- [**PostgreSQL Database**](/products/database/overview) - For storing your application data
- [**Authentication Service**](/products/auth/overview) - For managing users and sessions
- [**GraphQL API**](/products/graphql/overview) - For querying and mutating data
- [**File Storage**](/products/storage/overview) - For uploading and managing files
- [**Functions**](/products/functions/overview) - For running serverless functions
In the [next tutorial](/getting-started/tutorials/vue/2-protected-routes), you'll start building your Vue application and learn how to protect routes based on user authentication status.
<Tip>
Keep your project subdomain and region handy - you'll need them throughout the series to connect your Vue application to the Nhost backend.
</Tip>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,637 @@
---
title: User Authentication in Vue
description: Learn how to implement user authentication in a Vue application using Nhost
sidebarTitle: "User Authentication"
icon: user
---
This tutorial part builds upon the [Protected Routes part](/getting-started/tutorials/vue/2-protected-routes) by adding complete email/password authentication with email verification functionality. You'll implement sign up, sign in, email verification, and sign out features to create a full authentication flow.
<Info>
This is **Part 3** in the Full-Stack Vue Development with Nhost series. This part creates a production-ready authentication system with secure email verification and proper error handling.
</Info>
## Full-Stack Vue Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
**Current** - Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [Protected Routes part](/getting-started/tutorials/vue/2-protected-routes) first
- The project from the previous part set up and running
## Step-by-Step Guide
<Steps>
<Step>
### Create the Sign In Page
Build a comprehensive sign-in form with proper error handling and loading states. This page handles user authentication and includes special logic for post-verification sign-in.
```vue src/views/SignIn.vue lines
<template>
<div>
<h1>Sign In</h1>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="auth-form-field">
<label :for="emailId">Email</label>
<input
:id="emailId"
type="email"
v-model="email"
required
class="auth-input"
/>
</div>
<div class="auth-form-field">
<label :for="passwordId">Password</label>
<input
:id="passwordId"
type="password"
v-model="password"
required
class="auth-input"
/>
</div>
<div v-if="error" class="auth-error">
{{ error }}
</div>
<button
type="submit"
:disabled="isLoading"
class="auth-button secondary"
>
{{ isLoading ? "Signing In..." : "Sign In" }}
</button>
</form>
<div class="auth-links">
<p>
Don't have an account? <router-link to="/signup">Sign Up</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, useId } from "vue";
import { useRouter } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
const { nhost, isAuthenticated } = useAuth();
const router = useRouter();
const email = ref("");
const password = ref("");
const isLoading = ref(false);
const error = ref<string | null>(null);
const emailId = useId();
const passwordId = useId();
// Use onMounted for navigation after authentication is confirmed
onMounted(() => {
if (isAuthenticated.value) {
router.push("/profile");
}
});
const handleSubmit = async () => {
isLoading.value = true;
error.value = null;
try {
// Use the signIn function from auth context
const response = await nhost.auth.signInEmailPassword({
email: email.value,
password: password.value,
});
// If we have a session, sign in was successful
if (response.body?.session) {
router.push("/profile");
} else {
error.value = "Failed to sign in. Please check your credentials.";
}
} catch (err) {
const message = (err as Error).message || "Unknown error";
error.value = `An error occurred during sign in: ${message}`;
} finally {
isLoading.value = false;
}
};
</script>
```
</Step>
<Step>
### Create the Sign Up Page
Implement user registration with email verification flow. This page collects user information, creates accounts, and guides users through the email verification process.
```vue src/views/SignUp.vue lines
<template>
<div>
<h1 v-if="!success">Sign Up</h1>
<h1 v-else>Check Your Email</h1>
<div v-if="success" class="success-message">
<p>
We've sent a verification link to <strong>{{ email }}</strong>
</p>
<p>
Please check your email and click the verification link to activate your account.
</p>
<p>
<router-link to="/signin">Back to Sign In</router-link>
</p>
</div>
<form v-else @submit.prevent="handleSubmit" class="auth-form">
<div class="auth-form-field">
<label :for="displayNameId">Display Name</label>
<input
:id="displayNameId"
type="text"
v-model="displayName"
required
class="auth-input"
/>
</div>
<div class="auth-form-field">
<label :for="emailId">Email</label>
<input
:id="emailId"
type="email"
v-model="email"
required
class="auth-input"
/>
</div>
<div class="auth-form-field">
<label :for="passwordId">Password</label>
<input
:id="passwordId"
type="password"
v-model="password"
required
minlength="8"
class="auth-input"
/>
<small class="help-text">Minimum 8 characters</small>
</div>
<div v-if="error" class="auth-error">
{{ error }}
</div>
<button
type="submit"
:disabled="isLoading"
class="auth-button primary"
>
{{ isLoading ? "Creating Account..." : "Sign Up" }}
</button>
</form>
<div v-if="!success" class="auth-links">
<p>
Already have an account? <router-link to="/signin">Sign In</router-link>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, useId } from "vue";
import { useRouter } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
const { nhost, isAuthenticated } = useAuth();
const router = useRouter();
const email = ref("");
const password = ref("");
const displayName = ref("");
const isLoading = ref(false);
const error = ref<string | null>(null);
const success = ref(false);
const displayNameId = useId();
const emailId = useId();
const passwordId = useId();
// Redirect authenticated users to profile
onMounted(() => {
if (isAuthenticated.value) {
router.push("/profile");
}
});
const handleSubmit = async () => {
isLoading.value = true;
error.value = null;
success.value = false;
try {
const response = await nhost.auth.signUpEmailPassword({
email: email.value,
password: password.value,
options: {
displayName: displayName.value,
// Set the redirect URL for email verification
redirectTo: `${window.location.origin}/verify`,
},
});
if (response.body?.session) {
// Successfully signed up and automatically signed in
router.push("/profile");
} else {
// Verification email sent
success.value = true;
}
} catch (err) {
const message = (err as Error).message || "Unknown error";
error.value = `An error occurred during sign up: ${message}`;
} finally {
isLoading.value = false;
}
};
</script>
```
</Step>
<Step>
### Create the Email Verification Page
Build a dedicated verification page that processes email verification tokens. This page handles the verification flow when users click the email verification link.
```vue src/views/Verify.vue lines
<template>
<div>
<h1>Email Verification</h1>
<div class="page-center">
<div v-if="status === 'verifying'">
<p class="margin-bottom">Verifying your email...</p>
<div class="spinner-verify" />
</div>
<div v-else-if="status === 'success'">
<p class="verification-status">
✓ Successfully verified!
</p>
<p>You'll be redirected to your profile page shortly...</p>
</div>
<div v-else-if="status === 'error'">
<p class="verification-status error">
Verification failed
</p>
<p class="margin-bottom">{{ error }}</p>
<div v-if="Object.keys(urlParams).length > 0" class="debug-panel">
<p class="debug-title">
URL Parameters:
</p>
<div
v-for="[key, value] in Object.entries(urlParams)"
:key="key"
class="debug-item"
>
<span class="debug-key">
{{ key }}:
</span>
<span class="debug-value">{{ value }}</span>
</div>
</div>
<button
type="button"
@click="router.push('/signin')"
class="auth-button secondary"
>
Back to Sign In
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
const route = useRoute();
const router = useRouter();
const { nhost } = useAuth();
const status = ref<"verifying" | "success" | "error">("verifying");
const error = ref<string | null>(null);
const urlParams = ref<Record<string, string>>({});
// Flag to handle component unmounting during async operations
let isMounted = true;
onMounted(() => {
// Extract the refresh token from the URL
const params = new URLSearchParams(route.fullPath.split("?")[1] || "");
const refreshToken = params.get("refreshToken");
if (!refreshToken) {
// Collect all URL parameters to display for debugging
const allParams: Record<string, string> = {};
params.forEach((value, key) => {
allParams[key] = value;
});
urlParams.value = allParams;
status.value = "error";
error.value = "No refresh token found in URL";
return;
}
processToken(refreshToken, params);
});
onUnmounted(() => {
isMounted = false;
});
async function processToken(
refreshToken: string,
params: URLSearchParams,
): Promise<void> {
try {
// First display the verifying message for at least a moment
await new Promise((resolve) => setTimeout(resolve, 500));
if (!isMounted) return;
if (!refreshToken) {
// Collect all URL parameters to display
const allParams: Record<string, string> = {};
params.forEach((value, key) => {
allParams[key] = value;
});
urlParams.value = allParams;
status.value = "error";
error.value = "No refresh token found in URL";
return;
}
// Process the token
await nhost.auth.refreshToken({ refreshToken });
if (!isMounted) return;
status.value = "success";
// Wait to show success message briefly, then redirect
setTimeout(() => {
if (isMounted) router.push("/profile");
}, 1500);
} catch (err) {
const message = (err as Error).message || "Unknown error";
if (!isMounted) return;
status.value = "error";
error.value = `An error occurred during verification: ${message}`;
}
}
</script>
```
<Warning>
**Important Configuration Required:** Before testing email verification, you must configure your Nhost project's authentication settings:
1. Go to your Nhost project dashboard
2. Navigate to **Settings → Authentication**
3. Add your local development URL (e.g., `http://localhost:5173`) to the **Allowed Redirect URLs** field
4. Ensure your production domain is also added when deploying
Without this configuration, you'll receive a `redirectTo not allowed` error when users attempt to sign up or verify their email addresses.
</Warning>
</Step>
<Step>
### Update Router Configuration
Update your router configuration to include the new authentication routes:
```ts src/router/index.ts lines highlight={5-7,17-31}
import { createRouter, createWebHistory } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
import HomeView from "../views/HomeView.vue";
import ProfileView from "../views/ProfileView.vue";
import SignIn from "../views/SignIn.vue";
import SignUp from "../views/SignUp.vue";
import Verify from "../views/Verify.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/signin",
name: "SignIn",
component: SignIn,
},
{
path: "/signup",
name: "SignUp",
component: SignUp,
},
{
path: "/verify",
name: "Verify",
component: Verify,
},
{
path: "/profile",
name: "profile",
component: ProfileView,
meta: { requiresAuth: true },
},
{
path: "/:pathMatch(.*)*",
redirect: "/",
},
],
});
// Navigation guard for protected routes
router.beforeEach((to) => {
if (to.meta["requiresAuth"]) {
const { isAuthenticated, isLoading } = useAuth();
// Show loading state while authentication is being checked
if (isLoading.value) {
// You can return a loading component path or handle loading in the component
return true; // Allow navigation, handle loading in component
}
if (!isAuthenticated.value) {
return "/"; // Redirect to home page
}
}
return true;
});
export default router;
```
</Step>
<Step>
### Add Navigation Links and Sign Out Functionality
Update the navigation component to include links to the sign-in and sign-up pages, and implement the sign-out.
```vue src/components/Navigation.vue lines highlight={17-22,25-30,38,41-55}
<template>
<nav class="navigation">
<div class="nav-container">
<RouterLink to="/" class="nav-logo">
Nhost Vue Demo
</RouterLink>
<div class="nav-links">
<RouterLink to="/" class="nav-link">
Home
</RouterLink>
<template v-if="isAuthenticated">
<RouterLink to="/profile" class="nav-link">
Profile
</RouterLink>
<button
@click="handleSignOut"
class="nav-link nav-button"
>
Sign Out
</button>
</template>
<template v-else>
<RouterLink to="/signin" class="nav-link">
Sign In
</RouterLink>
<RouterLink to="/signup" class="nav-link">
Sign Up
</RouterLink>
</template>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { RouterLink, useRouter } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
const { isAuthenticated, session, nhost } = useAuth();
const router = useRouter();
const handleSignOut = async () => {
try {
if (session.value) {
await nhost.auth.signOut({
refreshToken: session.value.refreshToken,
});
}
router.push("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("Error signing out:", message);
}
};
</script>
```
</Step>
<Step>
### Run and Test the Application
Start your development server and test the complete authentication flow to ensure everything works properly.
```bash
npm run dev
```
Things to try out:
1. Try signing up with a new email address. Check your email for the verification link and click it. See how you are sent to the verification page and then redirected to your profile.
2. Try signing out and then signing back in with the same credentials.
3. Notice how navigation links change based on authentication state showing "Sign In" and "Sign Up" when logged out, and "Profile" and "Sign Out" when logged in.
4. Check how the homepage also reflects the authentication state with appropriate messages.
5. Open multiple tabs and test signing out from one tab to see how other tabs respond. Now sign back in and see the changes propagate across tabs.
</Step>
</Steps>
## Key Features Demonstrated
<AccordionGroup>
<Accordion title="Complete Registration Flow" icon="user-plus">
Full email/password registration with proper form validation and user feedback.
</Accordion>
<Accordion title="Email Verification" icon="envelope-circle-check">
Custom `/verify` endpoint that securely processes email verification tokens.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling for unverified emails, failed authentication, and network issues.
</Accordion>
<Accordion title="Visual Feedback" icon="eye">
Loading states, success messages, and clear error displays throughout the authentication flow.
</Accordion>
<Accordion title="Session Management" icon="clock">
Complete sign out functionality and proper session state management across the application.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,828 @@
---
title: GraphQL Operations in Vue
description: Learn how to perform GraphQL operations and manage database permissions while building a complete todos application with Nhost and Vue
sidebarTitle: "GraphQL Operations"
icon: code
---
This part builds upon the previous parts by demonstrating how to perform GraphQL operations with proper database permissions. You'll learn how to design database tables, configure user permissions, and implement complete CRUD operations through GraphQL queries and mutations in a real todos application.
<Info>
This is **Part 4** in the Full-Stack Vue Development with Nhost series. This part focuses on GraphQL operations, database management, and permission-based data access control in a production application.
</Info>
## Full-Stack Vue Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
**Current** - CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [User Authentication part](/getting-started/tutorials/vue/3-user-authentication) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- **GraphQL queries and mutations** for complete CRUD operations
- **Database schema** with proper relationships and constraints
- **User permissions** for secure data access control
- **Vue components** that interact with GraphQL endpoint
## Step-by-Step Guide
<Steps>
<Step>
### Create the To-Dos Table
First, we'll perform the database changes to set up the todos table with proper schema and relationships to users.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Click on the SQL Editor
Enter the following SQL:
<Tabs>
<Tab title="SQL">
```sql
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
details text,
completed bool DEFAULT false NOT NULL,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_todos_updated_at
BEFORE UPDATE ON public.todos
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
```
</Tab>
<Tab title="UI">
![Database SQL Editor](/images/tutorials/todos/1.png)
</Tab>
</Tabs>
<Warning>
Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs
</Warning>
</Step>
<Step>
### Set Up Permissions
Its now time to set permission rules for the table you just created. With the table `todos` selected, click on **…**, followed by **Edit Permissions**.
You will set permissions for the **user** role and actions **insert**, **select**, **update**, and **delete**.
<Tabs>
<Tab title="Insert">
When inserting permissions we are only allowing users to set the `title`, `details`, and `completed` columns as the rest of the columns are set automatically by the backend. The `user_id` column is configured as a preset to the currently authenticated user's ID using the `X-Hasura-User-Id` session variable. This ensures that each todo is associated with the user who created it.
![Insert Permissions Configuration](/images/tutorials/todos/2.png)
</Tab>
<Tab title="Select">
For selecting (reading) todos, we are allowing to read all columns but only for rows where the `user_id` matches the authenticated user's ID. This ensures that users can only see their own todos.
![Select Permissions Configuration](/images/tutorials/todos/3.png)
</Tab>
<Tab title="Update">
When updating todos, we are allowing users to modify the `title`, `details`, and `completed` columns but only for rows where the `user_id` matches their own ID. This prevents users from modifying todos that do not belong to them.
![Update Permissions Configuration](/images/tutorials/todos/4.png)
</Tab>
<Tab title="Delete">
For deleting todos, we are allowing users to delete rows only where the `user_id` matches their own ID. This ensures that users cannot delete todos that belong to other users.
![Delete Permissions Configuration](/images/tutorials/todos/5.png)
</Tab>
</Tabs>
</Step>
<Step>
### Create the Todos Page Component
Now let's implement the Vue component that uses the database we just configured.
```vue src/views/Todos.vue lines
<template>
<div v-if="!session" class="auth-message">
<p>Please sign in to view your todos.</p>
</div>
<div v-else class="container">
<header class="page-header">
<h1 class="page-title">
My Todos
<button
v-if="!showAddForm"
type="button"
@click="showAddForm = true"
class="add-todo-btn"
title="Add a new todo"
>
+
</button>
</h1>
</header>
<div v-if="error" class="error-message">
<strong>Error:</strong> {{ error }}
</div>
<div v-if="showAddForm" class="todo-form-card">
<form @submit.prevent="addTodo" class="todo-form">
<h2 class="form-title">Add New Todo</h2>
<div class="form-fields">
<div class="field-group">
<label :for="titleId">Title *</label>
<input
:id="titleId"
type="text"
v-model="newTodoTitle"
placeholder="What needs to be done?"
required
/>
</div>
<div class="field-group">
<label :for="detailsId">Details</label>
<textarea
:id="detailsId"
v-model="newTodoDetails"
placeholder="Add some details (optional)..."
:rows="3"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Add Todo
</button>
<button
type="button"
@click="cancelAddForm"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</form>
</div>
<div v-if="!showAddForm">
<div v-if="loading" class="loading-container">
<div class="loading-content">
<div class="spinner"></div>
<span class="loading-text">Loading todos...</span>
</div>
</div>
<div v-else class="todos-list">
<div v-if="todos.length === 0" class="empty-state">
<svg
class="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 class="empty-title">No todos yet</h3>
<p class="empty-description">
Create your first todo to get started!
</p>
</div>
<div
v-else
v-for="todo in todos"
:key="todo.id"
:class="['todo-card', { completed: todo.completed }]"
>
<div v-if="editingTodo?.id === todo.id" class="todo-edit">
<div class="edit-fields">
<div class="field-group">
<label :for="`${titleId}-edit`">Title</label>
<input
:id="`${titleId}-edit`"
type="text"
v-model="editingTodo.title"
/>
</div>
<div class="field-group">
<label :for="`${detailsId}-edit`">Details</label>
<textarea
:id="`${detailsId}-edit`"
v-model="editingTodo.details"
:rows="3"
/>
</div>
<div class="edit-actions">
<button
type="button"
@click="saveEdit"
class="btn btn-primary"
>
✓ Save Changes
</button>
<button
type="button"
@click="editingTodo = null"
class="btn btn-cancel"
>
✕ Cancel
</button>
</div>
</div>
</div>
<div v-else class="todo-content">
<div class="todo-header">
<button
type="button"
:class="['todo-title-btn', { completed: todo.completed }]"
@click="toggleTodoExpansion(todo.id)"
>
{{ todo.title }}
</button>
<div class="todo-actions">
<button
type="button"
@click="toggleComplete(todo)"
class="action-btn action-btn-complete"
:title="todo.completed ? 'Mark as incomplete' : 'Mark as complete'"
>
{{ todo.completed ? "↶" : "✓" }}
</button>
<button
type="button"
@click="editingTodo = todo"
class="action-btn action-btn-edit"
title="Edit todo"
>
✏️
</button>
<button
type="button"
@click="deleteTodo(todo.id)"
class="action-btn action-btn-delete"
title="Delete todo"
>
🗑️
</button>
</div>
</div>
<div v-if="expandedTodos.has(todo.id)" class="todo-details">
<div
v-if="todo.details"
:class="['todo-description', { completed: todo.completed }]"
>
<p>{{ todo.details }}</p>
</div>
<div class="todo-meta">
<div class="meta-dates">
<span class="meta-item">
Created: {{ new Date(todo.created_at).toLocaleString() }}
</span>
<span class="meta-item">
Updated: {{ new Date(todo.updated_at).toLocaleString() }}
</span>
</div>
<div v-if="todo.completed" class="completion-badge">
<svg
class="completion-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Completed</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, useId } from "vue";
import { useAuth } from "../lib/nhost/auth";
// The interfaces below define the structure of our data
// They are not strictly necessary but help with type safety
// Represents a single todo item
interface Todo {
id: string;
title: string;
details: string | null;
completed: boolean;
created_at: string;
updated_at: string;
user_id: string;
}
// This matches the GraphQL response structure for fetching todos
// Can be used as a generic type on the request method
interface GetTodos {
todos: Todo[];
}
// This matches the GraphQL response structure for inserting a todo
// Can be used as a generic type on the request method
interface InsertTodo {
insert_todos_one: Todo | null;
}
// This matches the GraphQL response structure for updating a todo
// Can be used as a generic type on the request method
interface UpdateTodo {
update_todos_by_pk: Todo | null;
}
const { nhost, session } = useAuth();
const todos = ref<Todo[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const newTodoTitle = ref("");
const newTodoDetails = ref("");
const editingTodo = ref<Todo | null>(null);
const showAddForm = ref(false);
const expandedTodos = ref<Set<string>>(new Set());
const titleId = useId();
const detailsId = useId();
const fetchTodos = async () => {
try {
loading.value = true;
// Make GraphQL request to fetch todos using Nhost client
// The query automatically filters by user_id due to Hasura permissions
const response = await nhost.graphql.request<GetTodos>({
query: `
query GetTodos {
todos(order_by: { created_at: desc }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
});
// Check for GraphQL errors in the response body
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch todos",
);
}
// Extract todos from the GraphQL response data
todos.value = response.body?.data?.todos || [];
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to fetch todos";
} finally {
loading.value = false;
}
};
const addTodo = async () => {
if (!newTodoTitle.value.trim()) return;
try {
// Execute GraphQL mutation to insert a new todo
// user_id is automatically set by Hasura based on JWT token
const response = await nhost.graphql.request<InsertTodo>({
query: `
mutation InsertTodo($title: String!, $details: String) {
insert_todos_one(object: { title: $title, details: $details }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
title: newTodoTitle.value.trim(),
details: newTodoDetails.value.trim() || null,
},
});
if (response.body.errors) {
throw new Error(response.body.errors[0]?.message || "Failed to add todo");
}
if (!response.body?.data?.insert_todos_one) {
throw new Error("Failed to add todo");
}
todos.value = [response.body?.data?.insert_todos_one, ...todos.value];
newTodoTitle.value = "";
newTodoDetails.value = "";
showAddForm.value = false;
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to add todo";
}
};
const updateTodo = async (
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
) => {
try {
// Execute GraphQL mutation to update an existing todo by primary key
// Hasura permissions ensure users can only update their own todos
const response = await nhost.graphql.request<UpdateTodo>({
query: `
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
id,
updates,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to update todo",
);
}
if (!response.body?.data?.update_todos_by_pk) {
throw new Error("Failed to update todo");
}
const updatedTodo = response.body?.data?.update_todos_by_pk;
if (updatedTodo) {
todos.value = todos.value.map((todo) =>
todo.id === id ? updatedTodo : todo,
);
}
editingTodo.value = null;
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to update todo";
}
};
const deleteTodo = async (id: string) => {
if (!confirm("Are you sure you want to delete this todo?")) return;
try {
// Execute GraphQL mutation to delete a todo by primary key
// Hasura permissions ensure users can only delete their own todos
const response = await nhost.graphql.request({
query: `
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
variables: {
id,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to delete todo",
);
}
todos.value = todos.value.filter((todo) => todo.id !== id);
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to delete todo";
}
};
const toggleComplete = async (todo: Todo) => {
await updateTodo(todo.id, { completed: !todo.completed });
};
const saveEdit = async () => {
if (!editingTodo.value) return;
await updateTodo(editingTodo.value.id, {
title: editingTodo.value.title,
details: editingTodo.value.details,
});
};
const toggleTodoExpansion = (todoId: string) => {
const newExpanded = new Set(expandedTodos.value);
if (newExpanded.has(todoId)) {
newExpanded.delete(todoId);
} else {
newExpanded.add(todoId);
}
expandedTodos.value = newExpanded;
};
const cancelAddForm = () => {
showAddForm.value = false;
newTodoTitle.value = "";
newTodoDetails.value = "";
};
// Fetch todos when user session is available
// The session contains the JWT token needed for GraphQL authentication
onMounted(() => {
if (session.value) {
fetchTodos();
}
});
</script>
```
</Step>
<Step>
### Update Router Configuration
Add the todos page to your application routing.
```ts src/router/index.ts lines highlight={7,39-44}
import { createRouter, createWebHistory } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
import HomeView from "../views/HomeView.vue";
import ProfileView from "../views/ProfileView.vue";
import SignIn from "../views/SignIn.vue";
import SignUp from "../views/SignUp.vue";
import Todos from "../views/Todos.vue";
import Verify from "../views/Verify.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/signin",
name: "SignIn",
component: SignIn,
},
{
path: "/signup",
name: "SignUp",
component: SignUp,
},
{
path: "/verify",
name: "Verify",
component: Verify,
},
{
path: "/profile",
name: "profile",
component: ProfileView,
meta: { requiresAuth: true },
},
{
path: "/todos",
name: "Todos",
component: Todos,
meta: { requiresAuth: true },
},
{
path: "/:pathMatch(.*)*",
redirect: "/",
},
],
});
// Navigation guard for protected routes
router.beforeEach((to) => {
if (to.meta["requiresAuth"]) {
const { isAuthenticated, isLoading } = useAuth();
// Show loading state while authentication is being checked
if (isLoading.value) {
// You can return a loading component path or handle loading in the component
return true; // Allow navigation, handle loading in component
}
if (!isAuthenticated.value) {
return "/"; // Redirect to home page
}
}
return true;
});
export default router;
```
</Step>
<Step>
### Update Navigation Links
Add a link to the todos page in the navigation bar.
```vue src/components/Navigation.vue lines highlight={14-16}
<template>
<nav class="navigation">
<div class="nav-container">
<RouterLink to="/" class="nav-logo">
Nhost Vue Demo
</RouterLink>
<div class="nav-links">
<RouterLink to="/" class="nav-link">
Home
</RouterLink>
<template v-if="isAuthenticated">
<RouterLink to="/todos" class="nav-link">
Todos
</RouterLink>
<RouterLink to="/profile" class="nav-link">
Profile
</RouterLink>
<button
@click="handleSignOut"
class="nav-link nav-button"
>
Sign Out
</button>
</template>
<template v-else>
<RouterLink to="/signin" class="nav-link">
Sign In
</RouterLink>
<RouterLink to="/signup" class="nav-link">
Sign Up
</RouterLink>
</template>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { RouterLink, useRouter } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
const { isAuthenticated, session, nhost } = useAuth();
const router = useRouter();
const handleSignOut = async () => {
try {
if (session.value) {
await nhost.auth.signOut({
refreshToken: session.value.refreshToken,
});
}
router.push("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("Error signing out:", message);
}
};
</script>
```
</Step>
<Step>
### Test Your Complete Application
Run your application and test all the functionality:
```bash
npm run dev
```
Things to try out:
1. Try signing in and out and see how the Todos page is only available when authenticated
2. Create, view, edit, complete, and delete todos. See how the UI updates accordingly
3. Open the application in another browser or incognito window, sign in with a different account and verify that you cannot see or modify todos from the first account
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Database Schema" icon="database">
Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance.
</Accordion>
<Accordion title="GraphQL API" icon="webhook">
Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos.
</Accordion>
<Accordion title="Row-Level Security" icon="shield-check">
Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations.
</Accordion>
<Accordion title="CRUD Operations" icon="arrows-rotate">
Complete Create, Read, Update, Delete functionality with proper error handling and user feedback.
</Accordion>
<Accordion title="Rich Interface" icon="sparkles">
Expandable todo items, inline editing, completion status, and detailed timestamps.
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,720 @@
---
title: File Uploads in Vue
description: Learn how to implement file upload functionality with storage buckets and permissions while building a complete file management system with Nhost and Vue
sidebarTitle: "File Uploads"
icon: upload
---
This part builds upon the previous GraphQL operations part by demonstrating how to implement file upload functionality with proper storage permissions. You'll learn how to create storage buckets, configure upload permissions, and implement complete file management operations in a Vue application.
<Info>
This is **Part 5** in the Full-Stack Vue Development with Nhost series. This part focuses on file storage, upload operations, and permission-based file access control in a production application.
</Info>
## Full-Stack Vue Development with Nhost
<CardGroup cols={3}>
<Card title="1. Create Project" icon="plus" href="/getting-started/tutorials/vue/1-introduction">
Set up your Nhost project
</Card>
<Card title="2. Protected Routes" icon="lock" href="/getting-started/tutorials/vue/2-protected-routes">
Route protection basics
</Card>
<Card title="3. User Authentication" icon="user" href="/getting-started/tutorials/vue/3-user-authentication">
Complete auth flow
</Card>
<Card title="4. GraphQL Operations" icon="list-check" href="/getting-started/tutorials/vue/4-graphql-operations">
CRUD operations with GraphQL
</Card>
<Card title="5. File Uploads" icon="upload" href="/getting-started/tutorials/vue/5-file-uploads">
**Current** - File upload and management
</Card>
</CardGroup>
## Prerequisites
- Complete the [GraphQL Operations part](/getting-started/tutorials/vue/4-graphql-operations) first
- The project from the previous part set up and running
## What You'll Build
By the end of this part, you'll have:
- A **personal bucket** so users can upload their own private files
- **File upload functionality**
- **File management interface** for viewing and deleting files
- **Security permissions** ensuring users can only access their own files
## Step-by-Step Guide
<Steps>
<Step>
### Create a Personal Storage Bucket
First, we'll create a storage bucket where users can upload their personal files.
In your Nhost project dashboard:
1. Navigate to **Database**
2. Change to **schema.storage**, then buckets
3. Now click on `+ Insert` on the top right corner.
4. As id set `personal`, leave the rest of the fields blank and click on Insert at the bottom
![Create bucket](/images/tutorials/uploads/1.png)
</Step>
<Step>
### Configure Storage Permissions
Now we need to set up permissions for the storage bucket to ensure the `user` role can only upload, view, and delete their own files.
<Tabs>
<Tab title="Upload">
To upload files we need to grant permissions to insert on the table `storage.files`. Because we want to allow uploading files only to the `personal` bucket we will be using the `bucket_id eq personal` as a custom check. In addition, we are configuring a preset `uploaded_by_user_id = X-Hasura-User-id`, this will automatically extract the user_id from the session and set the column accordingly. Then we can use this in other permissions to allow downloading files and deleting them.
![upload files permissions](/images/tutorials/uploads/2.png)
</Tab>
<Tab title="Download">
To download files users need to be able to query those files. To make sure users can only download files they uploaded we will be leveraging the column `uploaded_by_user_id` column from before and the `bucket_id``.
![download files permissions](/images/tutorials/uploads/3.png)
</Tab>
<Tab title="Delete">
Similarly to downloading files, to delete files users need to be able to delete rows from the `storage.files` table. Again we will use the `uploaded_by_user_id` column and the `bucket_id` to make sure users can only delete their own files.
![delete files permissions](/images/tutorials/uploads/4.png)
</Tab>
</Tabs>
<Info>
You can read more about storage permissions [here](/products/storage/overview#permissions)
</Info>
</Step>
<Step>
### Create the File Upload Component
Now let's implement the Vue component for file upload functionality.
```vue src/views/Files.vue lines
<template>
<div class="container">
<header class="page-header">
<h1 class="page-title">File Upload</h1>
</header>
<div class="form-card">
<h2 class="form-title">Upload a File</h2>
<div class="field-group">
<input
type="file"
ref="fileInputRef"
@change="handleFileChange"
style="
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
"
aria-hidden="true"
tabindex="-1"
/>
<button
type="button"
class="btn btn-secondary file-upload-btn"
@click="() => fileInputRef?.click()"
>
<svg
width="40"
height="40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
role="img"
aria-label="Upload file"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p>Click to select a file</p>
<p
v-if="selectedFile"
class="file-upload-info"
>
{{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }})
</p>
</button>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="uploadResult" class="success-message">
File uploaded successfully!
</div>
<button
type="button"
@click="handleUpload"
:disabled="!selectedFile || uploading"
class="btn btn-primary"
style="width: 100%"
>
{{ uploading ? "Uploading..." : "Upload File" }}
</button>
</div>
<div class="form-card">
<h2 class="form-title">Your Files</h2>
<div
v-if="deleteStatus"
:class="deleteStatus.isError ? 'error-message' : 'success-message'"
>
{{ deleteStatus.message }}
</div>
<div v-if="isFetching" class="loading-container">
<div class="loading-content">
<div class="spinner"></div>
<span class="loading-text">Loading files...</span>
</div>
</div>
<div v-else-if="files.length === 0" class="empty-state">
<svg
class="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="1.5"
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
<h3 class="empty-title">No files yet</h3>
<p class="empty-description">Upload your first file to get started!</p>
</div>
<div v-else style="overflow-x: auto">
<table class="file-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.id">
<td class="file-name">{{ file.name }}</td>
<td class="file-meta">{{ file.mimeType }}</td>
<td class="file-meta">{{ formatFileSize(file.size || 0) }}</td>
<td>
<div class="file-actions">
<button
type="button"
@click="
() =>
handleViewFile(
file.id || 'unknown',
file.name || 'unknown',
file.mimeType || 'unknown',
)
"
:disabled="viewingFile === file.id"
class="action-btn action-btn-edit"
title="View File"
>
{{ viewingFile === file.id ? "⏳" : "👁️" }}
</button>
<button
type="button"
@click="() => handleDeleteFile(file.id || 'unknown')"
:disabled="deleting === file.id"
class="action-btn action-btn-delete"
title="Delete File"
>
{{ deleting === file.id ? "⏳" : "🗑️" }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { FileMetadata } from "@nhost/nhost-js/storage";
import { onMounted, ref } from "vue";
import { useAuth } from "../lib/nhost/auth";
interface DeleteStatus {
message: string;
isError: boolean;
}
interface GraphqlGetFilesResponse {
files: FileMetadata[];
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return "0 Bytes";
const sizes: string[] = ["Bytes", "KB", "MB", "GB", "TB"];
const i: number = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
}
const { isAuthenticated, nhost } = useAuth();
const fileInputRef = ref<HTMLInputElement | null>(null);
const selectedFile = ref<File | null>(null);
const uploading = ref<boolean>(false);
const uploadResult = ref<FileMetadata | null>(null);
const isFetching = ref<boolean>(true);
const error = ref<string | null>(null);
const files = ref<FileMetadata[]>([]);
const viewingFile = ref<string | null>(null);
const deleting = ref<string | null>(null);
const deleteStatus = ref<DeleteStatus | null>(null);
const fetchFiles = async (): Promise<void> => {
isFetching.value = true;
error.value = null;
try {
// Use GraphQL to fetch files from the storage system
// Files are automatically filtered by user permissions
const response = await nhost.graphql.request<GraphqlGetFilesResponse>({
query: `query GetFiles {
files {
id
name
size
mimeType
bucketId
uploadedByUserId
}
}`,
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch files",
);
}
files.value = response.body.data?.files || [];
} catch (err) {
console.error("Error fetching files:", err);
error.value = "Failed to load files. Please try refreshing the page.";
} finally {
isFetching.value = false;
}
};
onMounted(() => {
if (isAuthenticated.value) {
fetchFiles();
}
});
const handleFileChange = (e: Event): void => {
const target = e.target as HTMLInputElement;
if (target.files && target.files.length > 0) {
const file = target.files[0];
if (file) {
selectedFile.value = file;
error.value = null;
uploadResult.value = null;
}
}
};
const handleUpload = async (): Promise<void> => {
if (!selectedFile.value) {
error.value = "Please select a file to upload";
return;
}
uploading.value = true;
error.value = null;
try {
// Upload file to the personal bucket
// The uploadedByUserId is automatically set by the storage permissions
const response = await nhost.storage.uploadFiles({
"bucket-id": "personal",
"file[]": [selectedFile.value],
});
const uploadedFile = response.body.processedFiles?.[0];
if (uploadedFile === undefined) {
throw new Error("Failed to upload file");
}
uploadResult.value = uploadedFile;
// Clear the form
selectedFile.value = null;
if (fileInputRef.value) {
fileInputRef.value.value = "";
}
// Update the files list
files.value = [uploadedFile, ...files.value];
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
uploadResult.value = null;
}, 3000);
} catch (err: unknown) {
const message = (err as Error).message || "An unknown error occurred";
error.value = `Failed to upload file: ${message}`;
} finally {
uploading.value = false;
}
};
const handleViewFile = async (
fileId: string,
fileName: string,
mimeType: string,
): Promise<void> => {
viewingFile.value = fileId;
try {
// Get the file from storage
const response = await nhost.storage.getFile(fileId);
const url = URL.createObjectURL(response.body);
// Handle different file types appropriately
if (
mimeType.startsWith("image/") ||
mimeType === "application/pdf" ||
mimeType.startsWith("text/") ||
mimeType.startsWith("video/") ||
mimeType.startsWith("audio/")
) {
// Open viewable files in new tab
window.open(url, "_blank");
} else {
// Download other file types
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Show download confirmation
const newWindow = window.open("", "_blank", "width=400,height=200");
if (newWindow) {
newWindow.document.documentElement.innerHTML = `
<head>
<title>File Download</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; text-align: center; }
</style>
</head>
<body>
<h3>Downloading: ${fileName}</h3>
<p>Your download has started. You can close this window.</p>
</body>
`;
}
}
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
error.value = `Failed to view file: ${message}`;
console.error("Error viewing file:", err);
} finally {
viewingFile.value = null;
}
};
const handleDeleteFile = async (fileId: string): Promise<void> => {
if (!fileId || deleting.value) return;
deleting.value = fileId;
error.value = null;
deleteStatus.value = null;
const fileToDelete = files.value.find((file) => file.id === fileId);
const fileName = fileToDelete?.name || "File";
try {
// Delete file from storage
// Permissions ensure users can only delete their own files
await nhost.storage.deleteFile(fileId);
deleteStatus.value = {
message: `${fileName} deleted successfully`,
isError: false,
};
// Remove from local state
files.value = files.value.filter((file) => file.id !== fileId);
await fetchFiles();
// Clear success message after 3 seconds
setTimeout(() => {
deleteStatus.value = null;
}, 3000);
} catch (err) {
const message = (err as Error).message || "An unknown error occurred";
deleteStatus.value = {
message: `Failed to delete ${fileName}: ${message}`,
isError: true,
};
console.error("Error deleting file:", err);
} finally {
deleting.value = null;
}
};
</script>
```
</Step>
<Step>
### Update Router Configuration
Add the files page to your application routing.
```ts src/router/index.ts lines highlight={8,46-51}
import { createRouter, createWebHistory } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
import Files from "../views/Files.vue";
import HomeView from "../views/HomeView.vue";
import ProfileView from "../views/ProfileView.vue";
import SignIn from "../views/SignIn.vue";
import SignUp from "../views/SignUp.vue";
import Todos from "../views/Todos.vue";
import Verify from "../views/Verify.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: HomeView,
},
{
path: "/signin",
name: "SignIn",
component: SignIn,
},
{
path: "/signup",
name: "SignUp",
component: SignUp,
},
{
path: "/verify",
name: "Verify",
component: Verify,
},
{
path: "/profile",
name: "profile",
component: ProfileView,
meta: { requiresAuth: true },
},
{
path: "/todos",
name: "Todos",
component: Todos,
meta: { requiresAuth: true },
},
{
path: "/files",
name: "Files",
component: Files,
meta: { requiresAuth: true },
},
{
path: "/:pathMatch(.*)*",
redirect: "/",
},
],
});
// Navigation guard for protected routes
router.beforeEach((to) => {
if (to.meta["requiresAuth"]) {
const { isAuthenticated, isLoading } = useAuth();
// Show loading state while authentication is being checked
if (isLoading.value) {
// You can return a loading component path or handle loading in the component
return true; // Allow navigation, handle loading in component
}
if (!isAuthenticated.value) {
return "/"; // Redirect to home page
}
}
return true;
});
export default router;
```
</Step>
<Step>
### Update Navigation Links
Add a link to the files page in the navigation bar.
```vue src/components/Navigation.vue lines highlight={17-19}
<template>
<nav class="navigation">
<div class="nav-container">
<RouterLink to="/" class="nav-logo">
Nhost Vue Demo
</RouterLink>
<div class="nav-links">
<RouterLink to="/" class="nav-link">
Home
</RouterLink>
<template v-if="isAuthenticated">
<RouterLink to="/todos" class="nav-link">
Todos
</RouterLink>
<RouterLink to="/files" class="nav-link">
Files
</RouterLink>
<RouterLink to="/profile" class="nav-link">
Profile
</RouterLink>
<button
@click="handleSignOut"
class="nav-link nav-button"
>
Sign Out
</button>
</template>
<template v-else>
<RouterLink to="/signin" class="nav-link">
Sign In
</RouterLink>
<RouterLink to="/signup" class="nav-link">
Sign Up
</RouterLink>
</template>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import { RouterLink, useRouter } from "vue-router";
import { useAuth } from "../lib/nhost/auth";
const { isAuthenticated, session, nhost } = useAuth();
const router = useRouter();
const handleSignOut = async () => {
try {
if (session.value) {
await nhost.auth.signOut({
refreshToken: session.value.refreshToken,
});
}
router.push("/");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error("Error signing out:", message);
}
};
</script>
```
</Step>
<Step>
### Test Your File Upload System
Run your application and test all the functionality:
```bash
npm run dev
```
Things to try out:
1. Try signing in and out and see how the file upload page is only accessible when signed in.
2. Upload different types of files (images, documents, etc.)
3. View and delete files
4. Sign in with another account and verify you cannot see files from the first account
</Step>
</Steps>
## Key Features Implemented
<AccordionGroup>
<Accordion title="Storage Bucket" icon="bucket">
Dedicated personal storage bucket with proper configuration for user file isolation.
</Accordion>
<Accordion title="File Upload Interface" icon="upload">
User-friendly upload interface with file selection, preview, and progress feedback.
</Accordion>
<Accordion title="File Management" icon="folder">
Complete file listing with metadata, viewing capabilities, and deletion functionality.
</Accordion>
<Accordion title="File Type Handling" icon="file">
Intelligent handling of different file types with appropriate viewing/download behavior.
</Accordion>
<Accordion title="Error Handling" icon="triangle-exclamation">
Comprehensive error handling with user-friendly messages for upload and management operations.
</Accordion>
</AccordionGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -1,3 +1,4 @@
import * as Linking from "expo-linking";
import { Link, router, useLocalSearchParams } from "expo-router";
import { useEffect, useState } from "react";
import {
@@ -25,6 +26,7 @@ export default function SignUp() {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [appleAuthInProgress, setAppleAuthInProgress] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<boolean>(false);
const [activeTab, setActiveTab] = useState<
"password" | "magic" | "social" | "native"
>("password");
@@ -41,6 +43,7 @@ export default function SignUp() {
const handleSubmit = async () => {
setIsLoading(true);
setError(null);
setSuccess(false);
try {
const response = await nhost.auth.signUpEmailPassword({
@@ -48,6 +51,7 @@ export default function SignUp() {
password,
options: {
displayName,
redirectTo: Linking.createURL("verify"),
},
});
@@ -55,8 +59,8 @@ export default function SignUp() {
// Successfully signed up and automatically signed in
router.replace("/profile");
} else {
// Verification email might be required
router.replace("/signin");
// Verification email sent
setSuccess(true);
}
} catch (err) {
const errMessage =
@@ -78,157 +82,185 @@ export default function SignUp() {
<Text style={styles.title}>Nhost SDK Demo</Text>
<View style={styles.card}>
<Text style={styles.cardTitle}>Sign Up</Text>
{magicLinkSent ? (
<View style={styles.messageContainer}>
<Text style={styles.successText}>
Magic link sent! Check your email to sign in.
</Text>
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => router.setParams({ magic: "" })}
>
<Text style={styles.secondaryButtonText}>Back to sign up</Text>
</TouchableOpacity>
</View>
{success ? (
<>
<Text style={styles.cardTitle}>Check Your Email</Text>
<View style={styles.messageContainer}>
<View style={styles.successMessageBox}>
<Text style={styles.successText}>
We've sent a verification link to{" "}
<Text style={styles.emailText}>{email}</Text>
</Text>
<Text style={styles.successText}>
Please check your email and click the verification link to
activate your account.
</Text>
</View>
<TouchableOpacity
style={styles.button}
onPress={() => router.replace("/signin")}
>
<Text style={styles.buttonText}>Back to Sign In</Text>
</TouchableOpacity>
</View>
</>
) : (
<>
<View style={styles.tabContainer}>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "password" && styles.activeTab,
]}
onPress={() => setActiveTab("password")}
>
<Text
style={[
styles.tabText,
activeTab === "password" && styles.activeTabText,
]}
>
Password
<Text style={styles.cardTitle}>Sign Up</Text>
{magicLinkSent ? (
<View style={styles.messageContainer}>
<Text style={styles.successText}>
Magic link sent! Check your email to sign in.
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "magic" && styles.activeTab,
]}
onPress={() => setActiveTab("magic")}
>
<Text
style={[
styles.tabText,
activeTab === "magic" && styles.activeTabText,
]}
<TouchableOpacity
style={styles.secondaryButton}
onPress={() => router.setParams({ magic: "" })}
>
Magic Link
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "social" && styles.activeTab,
]}
onPress={() => setActiveTab("social")}
>
<Text
style={[
styles.tabText,
activeTab === "social" && styles.activeTabText,
]}
>
Social
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "native" && styles.activeTab,
]}
onPress={() => setActiveTab("native")}
>
<Text
style={[
styles.tabText,
activeTab === "native" && styles.activeTabText,
]}
>
Native
</Text>
</TouchableOpacity>
</View>
<View style={styles.form}>
{activeTab === "password" ? (
<>
<View style={styles.inputGroup}>
<Text style={styles.label}>Display Name</Text>
<TextInput
style={styles.input}
value={displayName}
onChangeText={setDisplayName}
placeholder="Enter your name"
autoCapitalize="words"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
/>
<Text style={styles.helperText}>
Password must be at least 8 characters long
</Text>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
<Text style={styles.secondaryButtonText}>
Back to sign up
</Text>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.tabContainer}>
<TouchableOpacity
style={styles.button}
onPress={handleSubmit}
disabled={isLoading}
style={[
styles.tabButton,
activeTab === "password" && styles.activeTab,
]}
onPress={() => setActiveTab("password")}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Sign Up</Text>
)}
<Text
style={[
styles.tabText,
activeTab === "password" && styles.activeTabText,
]}
>
Password
</Text>
</TouchableOpacity>
</>
) : activeTab === "magic" ? (
<MagicLinkForm buttonLabel="Sign Up with Magic Link" />
) : activeTab === "social" ? (
<SocialLoginForm action="Sign Up" isLoading={isLoading} />
) : (
<NativeLoginForm
action="Sign Up"
isLoading={isLoading || appleAuthInProgress}
setAppleAuthInProgress={setAppleAuthInProgress}
/>
)}
</View>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "magic" && styles.activeTab,
]}
onPress={() => setActiveTab("magic")}
>
<Text
style={[
styles.tabText,
activeTab === "magic" && styles.activeTabText,
]}
>
Magic Link
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "social" && styles.activeTab,
]}
onPress={() => setActiveTab("social")}
>
<Text
style={[
styles.tabText,
activeTab === "social" && styles.activeTabText,
]}
>
Social
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tabButton,
activeTab === "native" && styles.activeTab,
]}
onPress={() => setActiveTab("native")}
>
<Text
style={[
styles.tabText,
activeTab === "native" && styles.activeTabText,
]}
>
Native
</Text>
</TouchableOpacity>
</View>
<View style={styles.form}>
{activeTab === "password" ? (
<>
<View style={styles.inputGroup}>
<Text style={styles.label}>Display Name</Text>
<TextInput
style={styles.input}
value={displayName}
onChangeText={setDisplayName}
placeholder="Enter your name"
autoCapitalize="words"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Enter your email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Password</Text>
<TextInput
style={styles.input}
value={password}
onChangeText={setPassword}
placeholder="Enter your password"
secureTextEntry
autoCapitalize="none"
/>
<Text style={styles.helperText}>
Password must be at least 8 characters long
</Text>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
<TouchableOpacity
style={styles.button}
onPress={handleSubmit}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.buttonText}>Sign Up</Text>
)}
</TouchableOpacity>
</>
) : activeTab === "magic" ? (
<MagicLinkForm buttonLabel="Sign Up with Magic Link" />
) : activeTab === "social" ? (
<SocialLoginForm action="Sign Up" isLoading={isLoading} />
) : (
<NativeLoginForm
action="Sign Up"
isLoading={isLoading || appleAuthInProgress}
setAppleAuthInProgress={setAppleAuthInProgress}
/>
)}
</View>
</>
)}
</>
)}
</View>
@@ -343,6 +375,18 @@ const styles = StyleSheet.create({
textAlign: "center",
marginBottom: 15,
},
successMessageBox: {
backgroundColor: "#f0fff4",
borderColor: "#38a169",
borderWidth: 1,
borderRadius: 8,
padding: 16,
marginBottom: 20,
},
emailText: {
fontWeight: "bold",
color: "#2d3748",
},
messageContainer: {
alignItems: "center",
paddingVertical: 10,

View File

@@ -35,14 +35,13 @@ select_permissions:
- role: user
permission:
columns:
- credential_id
- id
- nickname
- user_id
- credential_id
- nickname
filter:
user_id:
_eq: X-Hasura-User-Id
comment: ""
update_permissions:
- role: user
permission:
@@ -52,11 +51,3 @@ update_permissions:
user_id:
_eq: X-Hasura-User-Id
check: null
comment: ""
delete_permissions:
- role: user
permission:
filter:
user_id:
_eq: X-Hasura-User-Id
comment: ""

View File

@@ -120,27 +120,3 @@ array_relationships:
table:
name: user_providers
schema: auth
select_permissions:
- role: user
permission:
columns:
- active_mfa_type
- display_name
- email
- id
- metadata
filter:
id:
_eq: X-Hasura-User-Id
comment: ""
update_permissions:
- role: user
permission:
columns:
- display_name
- metadata
filter:
id:
_eq: X-Hasura-User-Id
check: null
comment: ""

View File

@@ -55,60 +55,41 @@ object_relationships:
insert_permissions:
- role: user
permission:
check: {}
check:
bucket_id:
_eq: personal
set:
uploaded_by_user_id: x-hasura-User-Id
uploaded_by_user_id: X-Hasura-User-Id
columns:
- bucket_id
- id
- mime_type
- bucket_id
- name
- size
- uploaded_by_user_id
comment: ""
- mime_type
select_permissions:
- role: user
permission:
columns:
- is_uploaded
- size
- metadata
- bucket_id
- etag
- mime_type
- name
- id
- created_at
- updated_at
- id
- uploaded_by_user_id
filter:
uploaded_by_user_id:
_eq: X-Hasura-User-Id
comment: ""
update_permissions:
- role: user
permission:
columns:
- is_uploaded
- size
- metadata
- bucket_id
- etag
- mime_type
- name
- created_at
- updated_at
- id
- size
- mime_type
- etag
- is_uploaded
- uploaded_by_user_id
- metadata
filter:
uploaded_by_user_id:
_eq: X-Hasura-User-Id
check: {}
comment: ""
_and:
- bucket_id:
_eq: personal
- uploaded_by_user_id:
_eq: X-Hasura-User-Id
delete_permissions:
- role: user
permission:
filter:
uploaded_by_user_id:
_eq: X-Hasura-User-Id
comment: ""

View File

@@ -7,11 +7,7 @@
- "!include auth_user_roles.yaml"
- "!include auth_user_security_keys.yaml"
- "!include auth_users.yaml"
- "!include public_attachments.yaml"
- "!include public_comments.yaml"
- "!include public_movies.yaml"
- "!include public_ninja_turtles.yaml"
- "!include public_tasks.yaml"
- "!include public_todos.yaml"
- "!include storage_buckets.yaml"
- "!include storage_files.yaml"
- "!include storage_virus.yaml"

View File

@@ -0,0 +1 @@
-- Could not auto-generate a down migration.

View File

@@ -0,0 +1,26 @@
CREATE TABLE public.todos (
id uuid DEFAULT gen_random_uuid() NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
title text NOT NULL,
details text,
completed bool DEFAULT false NOT NULL,
user_id uuid NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES auth.users (id) ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER update_todos_updated_at
BEFORE UPDATE ON public.todos
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();

View File

@@ -1,7 +1,7 @@
[global]
[hasura]
version = 'v2.46.0-ce'
version = 'v2.48.5-ce'
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'
webhookSecret = '{{ secrets.NHOST_WEBHOOK_SECRET }}'
@@ -28,7 +28,7 @@ httpPoolSize = 100
[functions]
[functions.node]
version = 20
version = 22
[auth]
version = '0.41.1'
@@ -64,7 +64,7 @@ rating = 'g'
[auth.session]
[auth.session.accessToken]
expiresIn = 65
expiresIn = 900
[auth.session.refreshToken]
expiresIn = 2592000
@@ -82,7 +82,7 @@ enabled = false
[auth.method.emailPassword]
hibpEnabled = false
emailVerificationRequired = false
emailVerificationRequired = true
passwordMinLength = 9
[auth.method.smsPasswordless]

View File

@@ -25,12 +25,17 @@ export async function signUp(formData: FormData) {
// Get the server Nhost client
const nhost = await createNhostClient();
// Get origin for redirect URL
const origin =
process.env["NEXT_PUBLIC_APP_URL"] || "http://localhost:3000";
// Sign up with email and password
const response = await nhost.auth.signUpEmailPassword({
email,
password,
options: {
displayName,
redirectTo: `${origin}/verify`,
},
});
@@ -43,6 +48,13 @@ export async function signUp(formData: FormData) {
return { redirect: "/profile" };
}
// If no session but no error, email verification was sent
if (response.body) {
return {
redirect: `/signup?verify=success&email=${encodeURIComponent(email)}`,
};
}
// If we got here, something went wrong
return { error: "Failed to sign up" };
} catch (err) {

View File

@@ -9,51 +9,82 @@ import SignUpForm from "./SignUpForm";
export default async function SignUp({
searchParams,
}: {
searchParams: Promise<{ error?: string; magic?: string }>;
searchParams: Promise<{
error?: string;
magic?: string;
verify?: string;
email?: string;
}>;
}) {
// Extract error and magic link status from URL
const params = await searchParams;
const error = params?.error;
const magicLinkSent = params?.magic === "success";
const verificationSent = params?.verify === "success";
const email = params?.email;
return (
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
<div className="glass-card w-full p-8 mb-6">
<h2 className="text-2xl mb-6">Sign Up</h2>
{verificationSent ? (
<>
<h2 className="text-2xl mb-6">Check Your Email</h2>
{magicLinkSent ? (
<div className="text-center">
<p className="mb-4">
Magic link sent! Check your email to sign in.
</p>
<Link href="/signup" className="btn btn-secondary">
Back to sign up
</Link>
</div>
<div className="text-center py-4">
<div className="mb-4 p-4 bg-green-100 text-green-700 rounded-md">
<p className="mb-2">
We've sent a verification link to <strong>{email}</strong>
</p>
<p>
Please check your email and click the verification link to
activate your account.
</p>
</div>
<Link href="/signin" className="btn btn-primary">
Back to Sign In
</Link>
</div>
</>
) : (
<TabForm
passwordTabContent={<SignUpForm initialError={error} />}
magicTabContent={
<div>
<MagicLinkForm
sendMagicLinkAction={sendMagicLink}
showDisplayName
buttonLabel="Sign up with Magic Link"
/>
</div>
}
socialTabContent={
<>
<h2 className="text-2xl mb-6">Sign Up</h2>
{magicLinkSent ? (
<div className="text-center">
<p className="mb-6">Sign up using your Social account</p>
<SocialSignIn provider="github" />
<p className="mb-4">
Magic link sent! Check your email to sign in.
</p>
<Link href="/signup" className="btn btn-secondary">
Back to sign up
</Link>
</div>
}
webauthnTabContent={
<WebAuthnSignUpForm buttonLabel="Sign up with Security Key" />
}
/>
) : (
<TabForm
passwordTabContent={<SignUpForm initialError={error} />}
magicTabContent={
<div>
<MagicLinkForm
sendMagicLinkAction={sendMagicLink}
showDisplayName
buttonLabel="Sign up with Magic Link"
/>
</div>
}
socialTabContent={
<div className="text-center">
<p className="mb-6">Sign up using your Social account</p>
<SocialSignIn provider="github" />
</div>
}
webauthnTabContent={
<WebAuthnSignUpForm buttonLabel="Sign up with Security Key" />
}
/>
)}
</>
)}
</div>

View File

@@ -17,14 +17,16 @@ export async function middleware(request: NextRequest) {
(route) => path === route || path.startsWith(`${route}/`),
);
// this is the only Nhost specific code in this middleware
// we call the Nhost middleware even on "public" routes
// to refresh the session if needed
const session = await handleNhostMiddleware(request, response);
// If it's a public route, allow access without checking auth
if (isPublicRoute) {
return response;
}
// this is the only Nhost specific code in this middleware
const session = await handleNhostMiddleware(request, response);
// If no session and not a public route, redirect to signin
if (!session) {
const signInUrl = new URL("/signin", request.url);

View File

@@ -14,6 +14,7 @@ import Home from "./pages/Home";
import Profile from "./pages/Profile";
import SignIn from "./pages/SignIn";
import SignUp from "./pages/SignUp";
import Todos from "./pages/Todos";
import Upload from "./pages/Upload";
import Verify from "./pages/Verify";
@@ -59,6 +60,7 @@ const router = createBrowserRouter(
<Route path="verify" element={<Verify />} />
<Route element={<ProtectedRoute />}>
<Route path="profile" element={<Profile />} />
<Route path="todos" element={<Todos />} />
<Route path="upload" element={<Upload />} />
</Route>
<Route path="*" element={<Navigate to="/" />} />

View File

@@ -25,6 +25,9 @@ export default function Navigation(): JSX.Element {
>
Profile
</Link>
<Link to="/todos" className={`nav-link ${isActive("/todos")}`}>
Todos
</Link>
<Link
to="/upload"
className={`nav-link ${isActive("/upload")}`}

View File

@@ -554,3 +554,203 @@ pre {
.tab-content {
margin-top: 1.5rem;
}
/* Additional utility classes */
.w-4 {
width: 1rem;
}
.w-5 {
width: 1.25rem;
}
.w-6 {
width: 1.5rem;
}
.w-16 {
width: 4rem;
}
.h-4 {
height: 1rem;
}
.h-5 {
height: 1.25rem;
}
.h-6 {
height: 1.5rem;
}
.h-16 {
height: 4rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-3 {
margin-bottom: 0.75rem;
}
.pt-3 {
padding-top: 0.75rem;
}
.p-3 {
padding: 0.75rem;
}
.space-x-1 > * + * {
margin-left: 0.25rem;
}
.space-x-2 > * + * {
margin-left: 0.5rem;
}
.space-x-3 > * + * {
margin-left: 0.75rem;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
.flex-1 {
flex: 1;
}
.inline-block {
display: inline-block;
}
.leading-relaxed {
line-height: 1.625;
}
.rounded {
border-radius: 0.375rem;
}
.rounded-full {
border-radius: 9999px;
}
.border {
border-width: 1px;
}
.border-2 {
border-width: 2px;
}
.border-t {
border-top-width: 1px;
}
.border-t-transparent {
border-top-color: transparent;
}
.opacity-75 {
opacity: 0.75;
}
.line-through {
text-decoration-line: line-through;
}
.cursor-pointer {
cursor: pointer;
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.duration-200 {
transition-duration: 200ms;
}
.hover\:shadow-lg:hover {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.hover\:border-secondary:hover {
border-color: var(--secondary);
}
/* Color utilities using CSS variables */
.text-primary {
color: var(--text-primary);
}
.text-secondary {
color: var(--text-secondary);
}
.text-muted {
color: var(--text-muted);
}
.bg-card-bg {
background-color: var(--card-bg);
}
.border-border-color {
border-color: var(--border-color);
}
.bg-secondary {
background-color: var(--secondary);
}
.border-secondary {
border-color: var(--secondary);
}
.border-primary {
border-color: var(--primary);
}
/* Spinning animation */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Floating Add Button */
.add-todo-btn {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
transition: all 0.3s ease;
}
.add-todo-btn:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
}
/* Small Add Button */
.add-todo-btn-small {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.2);
transition: all 0.2s ease;
}
.add-todo-btn-small:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
/* Text-only Add Button */
.add-todo-text-btn {
background: none;
border: none;
color: var(--primary);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
}
.add-todo-text-btn:hover {
color: var(--primary-hover);
transform: scale(1.1);
}
/* Todo title hover effect */
.hover\:text-primary-hover:hover {
color: var(--primary-hover);
}

View File

@@ -16,6 +16,7 @@ export default function SignUp(): JSX.Element {
const [displayName, setDisplayName] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const displayNameId = useId();
const emailId = useId();
const passwordId = useId();
@@ -31,6 +32,7 @@ export default function SignUp(): JSX.Element {
e.preventDefault();
setIsLoading(true);
setError(null);
setSuccess(false);
try {
const response = await nhost.auth.signUpEmailPassword({
@@ -38,15 +40,16 @@ export default function SignUp(): JSX.Element {
password,
options: {
displayName,
redirectTo: `${window.location.origin}/verify`,
},
});
if (response.body) {
if (response.body?.session) {
// Successfully signed up and automatically signed in
navigate("/profile");
} else {
// Verification email sent
navigate("/verify");
// Verification email sent or user created but needs verification
setSuccess(true);
}
} catch (err) {
const error = err as FetchError<ErrorResponse>;
@@ -69,6 +72,38 @@ export default function SignUp(): JSX.Element {
window.location.href = url;
};
if (success) {
return (
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
<div className="glass-card w-full p-8 mb-6">
<h2 className="text-2xl mb-6">Check Your Email</h2>
<div className="text-center py-4">
<div className="mb-4 p-4 bg-green-100 text-green-700 rounded-md">
<p className="mb-2">
We've sent a verification link to <strong>{email}</strong>
</p>
<p>
Please check your email and click the verification link to
activate your account.
</p>
</div>
<button
type="button"
onClick={() => navigate("/signin")}
className="btn btn-primary"
>
Back to Sign In
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center">
<h1 className="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>

View File

@@ -0,0 +1,648 @@
import type { JSX } from "react";
import { useCallback, useEffect, useId, useState } from "react";
import { useAuth } from "../lib/nhost/AuthProvider";
interface Todo {
id: string;
title: string;
details: string | null;
completed: boolean;
created_at: string;
updated_at: string;
user_id: string;
}
interface GetTodos {
todos: Todo[];
}
interface InsertTodo {
insert_todos_one: Todo | null;
}
interface UpdateTodo {
update_todos_by_pk: Todo | null;
}
export default function Todos(): JSX.Element {
const { nhost, session } = useAuth();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [newTodoTitle, setNewTodoTitle] = useState("");
const [newTodoDetails, setNewTodoDetails] = useState("");
const [editingTodo, setEditingTodo] = useState<Todo | null>(null);
const [showAddForm, setShowAddForm] = useState(false);
const [expandedTodos, setExpandedTodos] = useState<Set<string>>(new Set());
const titleId = useId();
const detailsId = useId();
const fetchTodos = useCallback(async () => {
try {
setLoading(true);
const response = await nhost.graphql.request<GetTodos>({
query: `
query GetTodos {
todos(order_by: { created_at: desc }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch todos",
);
}
setTodos(response.body?.data?.todos || []);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch todos");
} finally {
setLoading(false);
}
}, [nhost.graphql]);
const addTodo = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTodoTitle.trim()) return;
try {
const response = await nhost.graphql.request<InsertTodo>({
query: `
mutation InsertTodo($title: String!, $details: String) {
insert_todos_one(object: { title: $title, details: $details }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
title: newTodoTitle.trim(),
details: newTodoDetails.trim() || null,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to add todo",
);
}
if (!response.body?.data?.insert_todos_one) {
throw new Error("Failed to add todo");
}
setTodos([response.body?.data?.insert_todos_one, ...todos]);
setNewTodoTitle("");
setNewTodoDetails("");
setShowAddForm(false);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to add todo");
}
};
const updateTodo = async (
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
) => {
try {
const response = await nhost.graphql.request<UpdateTodo>({
query: `
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
id,
updates,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to update todo",
);
}
if (!response.body?.data?.update_todos_by_pk) {
throw new Error("Failed to update todo");
}
const updatedTodo = response.body?.data?.update_todos_by_pk;
if (updatedTodo) {
setTodos(todos.map((todo) => (todo.id === id ? updatedTodo : todo)));
}
setEditingTodo(null);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to update todo");
}
};
const deleteTodo = async (id: string) => {
if (!confirm("Are you sure you want to delete this todo?")) return;
try {
const response = await nhost.graphql.request({
query: `
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
variables: {
id,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to delete todo",
);
}
setTodos(todos.filter((todo) => todo.id !== id));
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete todo");
}
};
const toggleComplete = async (todo: Todo) => {
await updateTodo(todo.id, { completed: !todo.completed });
};
const saveEdit = async () => {
if (!editingTodo) return;
await updateTodo(editingTodo.id, {
title: editingTodo.title,
details: editingTodo.details,
});
};
const toggleTodoExpansion = (todoId: string) => {
const newExpanded = new Set(expandedTodos);
if (newExpanded.has(todoId)) {
newExpanded.delete(todoId);
} else {
newExpanded.add(todoId);
}
setExpandedTodos(newExpanded);
};
useEffect(() => {
if (session) {
fetchTodos();
}
}, [session, fetchTodos]);
if (!session) {
return (
<div className="text-center">
<p>Please sign in to view your todos.</p>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold gradient-text">My Todos</h1>
{!showAddForm && (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="add-todo-text-btn"
title="Add a new todo"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
)}
</div>
{error && (
<div className="alert alert-error">
<strong>Error:</strong> {error}
</div>
)}
{/* Add new todo form */}
{showAddForm && (
<div className="glass-card mb-8">
<form onSubmit={addTodo} className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Add New Todo</h2>
<button
type="button"
onClick={() => {
setShowAddForm(false);
setNewTodoTitle("");
setNewTodoDetails("");
}}
className="action-icon action-icon-delete"
title="Cancel"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="space-y-4">
<div>
<label htmlFor={titleId}>Title *</label>
<input
id={titleId}
type="text"
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
placeholder="What needs to be done?"
required
/>
</div>
<div>
<label htmlFor={detailsId}>Details</label>
<textarea
id={detailsId}
value={newTodoDetails}
onChange={(e) => setNewTodoDetails(e.target.value)}
placeholder="Add some details (optional)..."
rows={3}
/>
</div>
<div className="flex space-x-2">
<button type="submit" className="btn btn-primary flex-1">
<svg
className="w-4 h-4 mr-2 inline-block"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Add Todo
</button>
<button
type="button"
onClick={() => {
setShowAddForm(false);
setNewTodoTitle("");
setNewTodoDetails("");
}}
className="btn btn-secondary"
style={{
backgroundColor: "var(--text-muted)",
color: "white",
}}
>
Cancel
</button>
</div>
</div>
</form>
</div>
)}
{/* Todos list */}
{!showAddForm &&
(loading ? (
<div className="loading-container">
<div className="flex items-center space-x-2">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
<span className="text-secondary">Loading todos...</span>
</div>
</div>
) : (
<div className="space-y-4">
{todos.length === 0 ? (
<div className="glass-card p-8 text-center">
<svg
className="w-16 h-16 mx-auto mb-4 text-muted"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 className="text-lg font-medium mb-2">No todos yet</h3>
<p className="text-muted">
Create your first todo to get started!
</p>
</div>
) : (
todos.map((todo) => (
<div
key={todo.id}
className={`glass-card transition-all duration-200 ${
todo.completed ? "opacity-75" : "hover:shadow-lg"
}`}
>
{editingTodo?.id === todo.id ? (
/* Edit mode */
<div className="p-6">
<div className="space-y-4">
<div>
<label htmlFor={`${titleId}-edit`}>Title</label>
<input
id={`${titleId}-edit`}
type="text"
value={editingTodo.title}
onChange={(e) =>
setEditingTodo({
...editingTodo,
title: e.target.value,
})
}
/>
</div>
<div>
<label htmlFor={`${detailsId}-edit`}>Details</label>
<textarea
id={`${detailsId}-edit`}
value={editingTodo.details || ""}
onChange={(e) =>
setEditingTodo({
...editingTodo,
details: e.target.value,
})
}
rows={3}
/>
</div>
<div className="flex space-x-2">
<button
type="button"
onClick={saveEdit}
className="btn btn-secondary flex-1"
>
<svg
className="w-4 h-4 mr-2 inline-block"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Save Changes
</button>
<button
type="button"
onClick={() => setEditingTodo(null)}
className="btn btn-secondary flex-1"
style={{
backgroundColor: "var(--text-muted)",
color: "white",
}}
>
<svg
className="w-4 h-4 mr-2 inline-block"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Cancel
</button>
</div>
</div>
</div>
) : (
/* View mode */
<div className="p-6">
<div className="flex items-start justify-between mb-2">
<button
type="button"
className={`text-xl font-medium transition-all cursor-pointer hover:text-primary-hover text-left ${
todo.completed
? "line-through text-muted"
: "text-primary"
}`}
onClick={() => toggleTodoExpansion(todo.id)}
style={{
background: "none",
border: "none",
padding: 0,
}}
>
{todo.title}
</button>
<div className="table-actions">
<button
type="button"
onClick={() => toggleComplete(todo)}
className="action-icon action-icon-view"
title={
todo.completed
? "Mark as incomplete"
: "Mark as complete"
}
>
{todo.completed ? (
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
/>
</svg>
) : (
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
<button
type="button"
onClick={() => setEditingTodo(todo)}
className="action-icon action-icon-view"
title="Edit todo"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onClick={() => deleteTodo(todo.id)}
className="action-icon action-icon-delete"
title="Delete todo"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
{expandedTodos.has(todo.id) && (
<div className="mt-4 space-y-3">
{todo.details && (
<div
className={`p-3 rounded bg-card-bg border border-border-color ${
todo.completed ? "opacity-75" : ""
}`}
>
<p
className={`text-secondary leading-relaxed ${todo.completed ? "line-through" : ""}`}
>
{todo.details}
</p>
</div>
)}
<div className="flex items-center justify-between text-xs">
<div className="flex items-center space-x-3">
<span className="flex items-center space-x-1 text-muted">
<span>
Created:{" "}
{new Date(todo.created_at).toLocaleString()}
</span>
</span>
<span className="flex items-center space-x-1 text-muted">
Updated:{" "}
<span>
{new Date(todo.updated_at).toLocaleString()}
</span>
</span>
</div>
{todo.completed && (
<div className="flex items-center space-x-1 text-secondary">
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-xs font-medium">
Completed
</span>
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
))
)}
</div>
))}
</div>
);
}

View File

@@ -13,6 +13,7 @@ let password = $state("");
let displayName = $state("");
let isLoading = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
let params = $derived(new URLSearchParams($page.url.search));
let magicLinkSent = $derived(params.get("magic") === "success");
@@ -35,15 +36,16 @@ async function handleSubmit(e: Event) {
password,
options: {
displayName,
redirectTo: `${window.location.origin}/verify`,
},
});
if (response.body) {
if (response.body?.session) {
// Successfully signed up and automatically signed in
void goto("/profile");
} else {
// Verification email sent
void goto("/verify");
success = true;
}
} catch (err) {
const fetchError = err as FetchError<ErrorResponse>;
@@ -80,14 +82,32 @@ function setDisplayName(newDisplayName: string) {
<h1 class="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
<div class="glass-card w-full p-8 mb-6">
<h2 class="text-2xl mb-6">Sign Up</h2>
{#if success}
<h2 class="text-2xl mb-6">Check Your Email</h2>
{#if magicLinkSent}
<div class="text-center">
<p class="mb-4">Magic link sent! Check your email to sign up.</p>
<a href="/signup" class="btn btn-secondary"> Back to sign up </a>
<div class="text-center py-4">
<div class="mb-4 p-4 bg-green-100 text-green-700 rounded-md">
<p class="mb-2">
We've sent a verification link to <strong>{email}</strong>
</p>
<p>
Please check your email and click the verification link to activate your account.
</p>
</div>
<a href="/signin" class="btn btn-primary">
Back to Sign In
</a>
</div>
{:else}
<h2 class="text-2xl mb-6">Sign Up</h2>
{#if magicLinkSent}
<div class="text-center">
<p class="mb-4">Magic link sent! Check your email to sign up.</p>
<a href="/signup" class="btn btn-secondary"> Back to sign up </a>
</div>
{:else}
<TabForm>
{#snippet passwordTabContent()}
<form onsubmit={handleSubmit} class="space-y-5">
@@ -166,6 +186,7 @@ function setDisplayName(newDisplayName: string) {
/>
{/snippet}
</TabForm>
{/if}
{/if}
</div>

View File

@@ -666,3 +666,376 @@ pre {
.opacity-75 {
opacity: 0.75;
}
/* Todos specific styles */
.container {
max-width: 42rem;
margin: 0 auto;
padding: 1.5rem;
}
.auth-message {
text-align: center;
padding: 2rem;
background-color: rgba(31, 41, 55, 0.5);
border-radius: 0.5rem;
border: 1px solid var(--border-color);
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
}
.page-title {
font-size: 1.875rem;
font-weight: bold;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 1rem;
}
.add-todo-btn {
background-color: var(--primary);
color: white;
border: none;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
font-size: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.add-todo-btn:hover {
background-color: var(--primary-hover);
transform: scale(1.05);
}
.error-message {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.5);
color: var(--error);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.todo-form-card {
background-color: rgba(31, 41, 55, 0.8);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.5rem;
margin-bottom: 2rem;
}
.todo-form {
width: 100%;
}
.form-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1.5rem;
}
.form-fields {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-group label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.field-group input,
.field-group textarea {
width: 100%;
padding: 0.75rem;
background-color: rgba(17, 24, 39, 0.8);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
color: var(--text-primary);
font-size: 0.875rem;
transition: all 0.2s;
}
.field-group input:focus,
.field-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.form-actions {
display: flex;
gap: 0.75rem;
margin-top: 0.5rem;
}
.btn {
padding: 0.625rem 1rem;
border: none;
border-radius: 0.375rem;
font-weight: 500;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-cancel {
background-color: rgba(107, 114, 128, 0.2);
color: var(--text-secondary);
border: 1px solid var(--border-color);
}
.btn-cancel:hover {
background-color: rgba(107, 114, 128, 0.3);
color: var(--text-primary);
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
padding: 4rem 0;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.spinner {
width: 2rem;
height: 2rem;
border: 2px solid var(--border-color);
border-top: 2px solid var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
color: var(--text-secondary);
font-size: 0.875rem;
}
.todos-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--text-muted);
}
.empty-icon {
width: 4rem;
height: 4rem;
margin: 0 auto 1.5rem;
color: var(--text-muted);
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 0.5rem;
}
.empty-description {
color: var(--text-muted);
font-size: 0.875rem;
}
.todo-card {
background-color: rgba(31, 41, 55, 0.8);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1.5rem;
transition: all 0.2s;
}
.todo-card:hover {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.1);
}
.todo-card.completed {
opacity: 0.7;
background-color: rgba(34, 197, 94, 0.05);
border-color: rgba(34, 197, 94, 0.2);
}
.todo-edit {
width: 100%;
}
.edit-fields {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.edit-actions {
display: flex;
gap: 0.75rem;
}
.todo-content {
width: 100%;
}
.todo-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.todo-title-btn {
background: none;
border: none;
color: var(--text-primary);
font-size: 1rem;
font-weight: 500;
text-align: left;
cursor: pointer;
padding: 0;
flex: 1;
transition: color 0.2s;
}
.todo-title-btn:hover {
color: var(--primary);
}
.todo-title-btn.completed {
text-decoration: line-through;
color: var(--text-muted);
}
.todo-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.25rem;
border-radius: 0.25rem;
font-size: 0.875rem;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
height: 1.75rem;
}
.action-btn:hover {
background-color: rgba(31, 41, 55, 0.8);
}
.action-btn-complete {
color: var(--secondary);
}
.action-btn-complete:hover {
color: var(--secondary-hover);
background-color: rgba(16, 185, 129, 0.1);
}
.action-btn-edit:hover {
color: var(--primary);
background-color: rgba(99, 102, 241, 0.1);
}
.action-btn-delete:hover {
color: var(--error);
background-color: rgba(239, 68, 68, 0.1);
}
.todo-details {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.todo-description {
margin-bottom: 1rem;
}
.todo-description.completed {
opacity: 0.7;
}
.todo-description p {
color: var(--text-secondary);
line-height: 1.5;
margin: 0;
}
.todo-meta {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.meta-dates {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.meta-item {
font-size: 0.75rem;
color: var(--text-muted);
}
.completion-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background-color: rgba(34, 197, 94, 0.1);
color: var(--secondary);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
width: fit-content;
}
.completion-icon {
width: 0.875rem;
height: 0.875rem;
}

View File

@@ -5,6 +5,13 @@
<div class="navbar-links">
<template v-if="isAuthenticated">
<router-link
to="/todos"
class="nav-link"
:class="{ active: $route.path === '/todos' }"
>
Todos
</router-link>
<router-link
to="/profile"
class="nav-link"

View File

@@ -3,6 +3,7 @@ import { useAuth } from "../lib/nhost/auth";
import Profile from "../views/Profile.vue";
import SignIn from "../views/SignIn.vue";
import SignUp from "../views/SignUp.vue";
import Todos from "../views/Todos.vue";
import Upload from "../views/Upload.vue";
import Verify from "../views/Verify.vue";
@@ -34,6 +35,12 @@ const router = createRouter({
component: Profile,
meta: { requiresAuth: true },
},
{
path: "/todos",
name: "Todos",
component: Todos,
meta: { requiresAuth: true },
},
{
path: "/upload",
name: "Upload",

View File

@@ -2,7 +2,30 @@
<div class="flex flex-col items-center justify-center">
<h1 class="text-3xl mb-6 gradient-text">Nhost SDK Demo</h1>
<div class="glass-card w-full p-8 mb-6">
<div v-if="success" class="glass-card w-full p-8 mb-6">
<h2 class="text-2xl mb-6">Check Your Email</h2>
<div class="text-center py-4">
<div class="mb-4 p-4 bg-green-100 text-green-700 rounded-md">
<p class="mb-2">
We've sent a verification link to <strong>{{ email }}</strong>
</p>
<p>
Please check your email and click the verification link to activate your account.
</p>
</div>
<button
type="button"
@click="router.push('/signin')"
class="btn btn-primary"
>
Back to Sign In
</button>
</div>
</div>
<div v-else class="glass-card w-full p-8 mb-6">
<h2 class="text-2xl mb-6">Sign Up</h2>
<TabForm>
@@ -109,6 +132,7 @@ const password = ref<string>("");
const displayName = ref<string>("");
const isLoading = ref<boolean>(false);
const error = ref<string | null>(null);
const success = ref<boolean>(false);
// If already authenticated, redirect to profile
onMounted(() => {
@@ -127,15 +151,16 @@ const handleSubmit = async (): Promise<void> => {
password: password.value,
options: {
displayName: displayName.value,
redirectTo: `${window.location.origin}/verify`,
},
});
if (response.body) {
if (response.body?.session) {
// Successfully signed up and automatically signed in
router.push("/profile");
} else {
// Verification email sent
router.push("/verify");
success.value = true;
}
} catch (err) {
const errorObj = err as FetchError<ErrorResponse>;

View File

@@ -0,0 +1,466 @@
<template>
<div v-if="!session" class="auth-message">
<p>Please sign in to view your todos.</p>
</div>
<div v-else class="container">
<header class="page-header">
<h1 class="page-title">
My Todos
<button
v-if="!showAddForm"
type="button"
@click="showAddForm = true"
class="add-todo-btn"
title="Add a new todo"
>
+
</button>
</h1>
</header>
<div v-if="error" class="error-message">
<strong>Error:</strong> {{ error }}
</div>
<div v-if="showAddForm" class="todo-form-card">
<form @submit.prevent="addTodo" class="todo-form">
<h2 class="form-title">Add New Todo</h2>
<div class="form-fields">
<div class="field-group">
<label :for="titleId">Title *</label>
<input
:id="titleId"
type="text"
v-model="newTodoTitle"
placeholder="What needs to be done?"
required
/>
</div>
<div class="field-group">
<label :for="detailsId">Details</label>
<textarea
:id="detailsId"
v-model="newTodoDetails"
placeholder="Add some details (optional)..."
:rows="3"
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Add Todo
</button>
<button
type="button"
@click="cancelAddForm"
class="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</form>
</div>
<div v-if="!showAddForm">
<div v-if="loading" class="loading-container">
<div class="loading-content">
<div class="spinner"></div>
<span class="loading-text">Loading todos...</span>
</div>
</div>
<div v-else class="todos-list">
<div v-if="todos.length === 0" class="empty-state">
<svg
class="empty-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
<h3 class="empty-title">No todos yet</h3>
<p class="empty-description">
Create your first todo to get started!
</p>
</div>
<div
v-else
v-for="todo in todos"
:key="todo.id"
:class="['todo-card', { completed: todo.completed }]"
>
<div v-if="editingTodo?.id === todo.id" class="todo-edit">
<div class="edit-fields">
<div class="field-group">
<label :for="`${titleId}-edit`">Title</label>
<input
:id="`${titleId}-edit`"
type="text"
v-model="editingTodo.title"
/>
</div>
<div class="field-group">
<label :for="`${detailsId}-edit`">Details</label>
<textarea
:id="`${detailsId}-edit`"
v-model="editingTodo.details"
:rows="3"
/>
</div>
<div class="edit-actions">
<button
type="button"
@click="saveEdit"
class="btn btn-primary"
>
Save Changes
</button>
<button
type="button"
@click="editingTodo = null"
class="btn btn-cancel"
>
Cancel
</button>
</div>
</div>
</div>
<div v-else class="todo-content">
<div class="todo-header">
<button
type="button"
:class="['todo-title-btn', { completed: todo.completed }]"
@click="toggleTodoExpansion(todo.id)"
>
{{ todo.title }}
</button>
<div class="todo-actions">
<button
type="button"
@click="toggleComplete(todo)"
class="action-btn action-btn-complete"
:title="todo.completed ? 'Mark as incomplete' : 'Mark as complete'"
>
{{ todo.completed ? "↶" : "✓" }}
</button>
<button
type="button"
@click="editingTodo = todo"
class="action-btn action-btn-edit"
title="Edit todo"
>
</button>
<button
type="button"
@click="deleteTodo(todo.id)"
class="action-btn action-btn-delete"
title="Delete todo"
>
🗑
</button>
</div>
</div>
<div v-if="expandedTodos.has(todo.id)" class="todo-details">
<div
v-if="todo.details"
:class="['todo-description', { completed: todo.completed }]"
>
<p>{{ todo.details }}</p>
</div>
<div class="todo-meta">
<div class="meta-dates">
<span class="meta-item">
Created: {{ new Date(todo.created_at).toLocaleString() }}
</span>
<span class="meta-item">
Updated: {{ new Date(todo.updated_at).toLocaleString() }}
</span>
</div>
<div v-if="todo.completed" class="completion-badge">
<svg
class="completion-icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
:stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Completed</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, useId } from "vue";
import { useAuth } from "../lib/nhost/auth";
// The interfaces below define the structure of our data
// They are not strictly necessary but help with type safety
// Represents a single todo item
interface Todo {
id: string;
title: string;
details: string | null;
completed: boolean;
created_at: string;
updated_at: string;
user_id: string;
}
// This matches the GraphQL response structure for fetching todos
// Can be used as a generic type on the request method
interface GetTodos {
todos: Todo[];
}
// This matches the GraphQL response structure for inserting a todo
// Can be used as a generic type on the request method
interface InsertTodo {
insert_todos_one: Todo | null;
}
// This matches the GraphQL response structure for updating a todo
// Can be used as a generic type on the request method
interface UpdateTodo {
update_todos_by_pk: Todo | null;
}
const { nhost, session } = useAuth();
const todos = ref<Todo[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const newTodoTitle = ref("");
const newTodoDetails = ref("");
const editingTodo = ref<Todo | null>(null);
const showAddForm = ref(false);
const expandedTodos = ref<Set<string>>(new Set());
const titleId = useId();
const detailsId = useId();
const fetchTodos = async () => {
try {
loading.value = true;
// Make GraphQL request to fetch todos using Nhost client
// The query automatically filters by user_id due to Hasura permissions
const response = await nhost.graphql.request<GetTodos>({
query: `
query GetTodos {
todos(order_by: { created_at: desc }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
});
// Check for GraphQL errors in the response body
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to fetch todos",
);
}
// Extract todos from the GraphQL response data
todos.value = response.body?.data?.todos || [];
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to fetch todos";
} finally {
loading.value = false;
}
};
const addTodo = async () => {
if (!newTodoTitle.value.trim()) return;
try {
// Execute GraphQL mutation to insert a new todo
// user_id is automatically set by Hasura based on JWT token
const response = await nhost.graphql.request<InsertTodo>({
query: `
mutation InsertTodo($title: String!, $details: String) {
insert_todos_one(object: { title: $title, details: $details }) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
title: newTodoTitle.value.trim(),
details: newTodoDetails.value.trim() || null,
},
});
if (response.body.errors) {
throw new Error(response.body.errors[0]?.message || "Failed to add todo");
}
if (!response.body?.data?.insert_todos_one) {
throw new Error("Failed to add todo");
}
todos.value = [response.body?.data?.insert_todos_one, ...todos.value];
newTodoTitle.value = "";
newTodoDetails.value = "";
showAddForm.value = false;
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to add todo";
}
};
const updateTodo = async (
id: string,
updates: Partial<Pick<Todo, "title" | "details" | "completed">>,
) => {
try {
// Execute GraphQL mutation to update an existing todo by primary key
// Hasura permissions ensure users can only update their own todos
const response = await nhost.graphql.request<UpdateTodo>({
query: `
mutation UpdateTodo($id: uuid!, $updates: todos_set_input!) {
update_todos_by_pk(pk_columns: { id: $id }, _set: $updates) {
id
title
details
completed
created_at
updated_at
user_id
}
}
`,
variables: {
id,
updates,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to update todo",
);
}
if (!response.body?.data?.update_todos_by_pk) {
throw new Error("Failed to update todo");
}
const updatedTodo = response.body?.data?.update_todos_by_pk;
if (updatedTodo) {
todos.value = todos.value.map((todo) =>
todo.id === id ? updatedTodo : todo,
);
}
editingTodo.value = null;
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to update todo";
}
};
const deleteTodo = async (id: string) => {
if (!confirm("Are you sure you want to delete this todo?")) return;
try {
// Execute GraphQL mutation to delete a todo by primary key
// Hasura permissions ensure users can only delete their own todos
const response = await nhost.graphql.request({
query: `
mutation DeleteTodo($id: uuid!) {
delete_todos_by_pk(id: $id) {
id
}
}
`,
variables: {
id,
},
});
if (response.body.errors) {
throw new Error(
response.body.errors[0]?.message || "Failed to delete todo",
);
}
todos.value = todos.value.filter((todo) => todo.id !== id);
error.value = null;
} catch (err) {
error.value = err instanceof Error ? err.message : "Failed to delete todo";
}
};
const toggleComplete = async (todo: Todo) => {
await updateTodo(todo.id, { completed: !todo.completed });
};
const saveEdit = async () => {
if (!editingTodo.value) return;
await updateTodo(editingTodo.value.id, {
title: editingTodo.value.title,
details: editingTodo.value.details,
});
};
const toggleTodoExpansion = (todoId: string) => {
const newExpanded = new Set(expandedTodos.value);
if (newExpanded.has(todoId)) {
newExpanded.delete(todoId);
} else {
newExpanded.add(todoId);
}
expandedTodos.value = newExpanded;
};
const cancelAddForm = () => {
showAddForm.value = false;
newTodoTitle.value = "";
newTodoDetails.value = "";
};
// Fetch todos when user session is available
// The session contains the JWT token needed for GraphQL authentication
onMounted(() => {
if (session.value) {
fetchTodos();
}
});
</script>

View File

@@ -4,12 +4,12 @@ include $(ROOT_DIR)/build/makefiles/general.makefile
.PHONY: _dev-env-up
_dev-env-up:
cd ../demos/backend/ && $(ROOT_DIR)/examples/demos/backend/env-up.sh
cd ./backend/ && $(ROOT_DIR)/examples/demos/backend/env-up.sh
.PHONY: _dev-env-down
_dev-env-down:
cd ../demos/backend/ && nhost down --volumes
cd ./backend/ && nhost down --volumes
.PHONY: _dev-env-build

2
examples/guides/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.nhost
.secrets

View File

@@ -0,0 +1,16 @@
GRAFANA_ADMIN_PASSWORD = 'grafana-admin-password'
HASURA_GRAPHQL_ADMIN_SECRET = 'nhost-admin-secret'
HASURA_GRAPHQL_JWT_SECRET = '55b1d038dff8d4f9a440e848250668527fa5b563700be0dc39e356f1c91f867e'
NHOST_WEBHOOK_SECRET = 'nhost-webhook-secret'
GITHUB_CLIENT_ID='fixme'
GITHUB_CLIENT_SECRET='fixme'
APPLE_TEAM_ID='fakeTeamId'
APPLE_CLIENT_ID='host.exp.Exponent'
APPLE_AUDIENCE='host.exp.Exponent'
APPLE_KEY_ID='fakeKeyId'
APPLE_PRIVATE_KEY='''-----BEGIN PRIVATE KEY-----
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQglHTWHjauHnKCxjEP
BpMYsTDI2cihQi4tAYHTthj+FF+gCgYIKoZIzj0DAQehRANCAAR30Hs8vTbED10z
Qx2m4sJu+lE/ZJsRvDkqLqYF8uh1Tb1g7/KKr8Y7qkK3DmCg72bCyirEq4NVUi2r
M/6TYMpw
-----END PRIVATE KEY-----'''

View File

@@ -0,0 +1,7 @@
.PHONY: dev-env-up
dev-env-up:
@./env-up.sh
.PHONY: dev-env-down
dev-env-down:
@nhost down --volumes

View File

@@ -0,0 +1,29 @@
# backend
This is a very simple Nhost backend that we will use to demonstrate how to use the various SDKs we are experimenting with. The backend will consist of the following:
## Database schema
- A `tasks` table with the following columns:
- `id` (UUID)
- `created_at` (Timestamp)
- `updated_at` (Timestamp)
- `user_id` (foreigh key to `auth.users.id`)
- `title` (Text)
- `description` (Text)
- `completed` (Boolean)
- An `attachments` table with the following columns:
- `task_id` (foreign key to `tasks.id`)
- `file_id` (foreign key to `storage.files.id`)
Permissions:
- `tasks`: the `user` role can insert/select/update tasks that they own. Ownership is tracked by the `user_id` column which is set automatically on insert from the session.
- `attachments`: the `user` role can insert/select/delete attachments for tasks and files that they own
- `storage.files`: the `user` role can insert/select/delete files that they own
## Functions
- A `simple` function called `echo` that will just return back some request information

View File

@@ -0,0 +1,8 @@
#!/bin/sh
# if .secrets file doesn't exist, cp .secrets.example .secrets
if [ ! -f .secrets ]; then
cp .secrets.example .secrets
fi
nhost up

View File

@@ -0,0 +1,14 @@
{
"name": "functions",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "functions",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {}
}
}
}

View File

@@ -0,0 +1,13 @@
{
"name": "functions",
"version": "1.0.0",
"description": "",
"main": "index.js",
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"allowJs": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"strictNullChecks": false
}
}

View File

@@ -0,0 +1 @@
version: 3

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Потвърдете смяната на вашия имейл</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да повърдите смяната на имейл:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Смени имейл</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Потвърждение за смяна на имейл

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Потвърдете вашия имейл</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да потвърдите вашия имейл:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Потвърдете имейл</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Потвърждаване на имейл

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Смяна на парола</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк, за да смените вашата парола:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Смяна на парола</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Смяна на парола

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">За да влезете в ${redirectTo}, моля, използвайте следната еднократна парола:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Еднократна парола за ${redirectTo}

View File

@@ -0,0 +1 @@
Вашият код е ${code}.

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Магически линк за вход</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Използвайте посочения линк за защитен и бърз вход:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Вход</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Магически линк за вход

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Potvrzení změny emailové adresy</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k potvrzení změny emailové adresy:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Změnit email</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Změna vaší emailové adresy

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Ověření emailové adresy</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k ověření vaší emailové adresy:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Ověřit emailovou adresu</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Ověření vaší emailové adresy

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Obnova hesla</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k obnovení vašeho hesla:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Obnova hesla</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Obnova hesla

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">One-time Password</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Pro přihlášení do ${redirectTo}, prosím, použijte následující jednorázové heslo:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td><p style="font-size: 24px; line-height: 32px; margin: 16px 0; color: #0052cd; font-weight: 600">${ticket}</p></td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Jednorázové heslo pro ${redirectTo}

View File

@@ -0,0 +1 @@
Váš kód je ${code}.

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html dir="ltr" lang="en">
<head>
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
<meta name="x-apple-disable-message-reformatting" />
</head>
<body style="background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif">
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="max-width: 560px; margin: 20px auto 0 auto; padding: 20px; background-color: #ffffff; border-radius: 8px; border: 1px solid #ececec">
<tbody>
<tr style="width: 100%">
<td>
<h1 style="font-size: 24px; letter-spacing: -0.5px; line-height: 1.3; font-weight: 400; color: #484848; margin-top: 0">Magický odkaz</h1>
<p style="font-size: 15px; line-height: 1.4; margin: 0 0 10px; color: #3c4149">Použijte tento odkaz k bezpečnému přihlášení:</p>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="padding: 10px 0 0px">
<tbody>
<tr>
<td>
<a href="${link}" style="line-height: 100%; text-decoration: none; display: block; max-width: 100%; background-color: #0052cd; border-radius: 3px; font-weight: 600; color: #fff; font-size: 15px; text-align: center; padding: 11px 23px 11px 23px" target="_blank"
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%; mso-text-raise: 16.5" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span
><span style="max-width: 100%; display: inline-block; line-height: 120%; mso-padding-alt: 0px; mso-text-raise: 8.25px">Přihlášení</span
><span
><!--[if mso]><i style="mso-font-width: 383.33333333333337%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span
></a
>
</td>
</tr>
</tbody>
</table>
<hr style="width: 100%; border: none; border-top: 1px solid #eaeaea; border-color: #dfe1e4; margin: 20px 0 20px" />
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody>
<tr>
<td>
<table align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tbody style="width: 100%">
<tr style="width: 100%">
<td data-id="__react-email-column" style="width: 30px"><img alt="Nhost Logo" height="20" src="https://nhost.io/images/emails/icon.png" style="display: block; outline: none; border: none; text-decoration: none; border-radius: 0; width: 20px; height: 20px" width="20" /></td>
<td data-id="__react-email-column" style="margin: 0"><a href="https://nhost.io" style="color: #b4becc; text-decoration: none; font-size: 14px" target="_blank">Powered by Nhost</a></td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1 @@
Bezpečný odkaz k přihlášení

Some files were not shown because too many files have changed in this diff Show More