### **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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-53f3b3d582fef21d5ec90cb590b73afcf09407071dba60883ed1ed7360955fc5">+648/-0</a> </td> </tr> <tr> <td><strong>todos.tsx</strong><dd><code>Add React Native tutorial Todos</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-3f6be5ef4f443091687addd404fe71f219498b9db7dea992d18d78b4f1b6ffa3">+561/-0</a> </td> </tr> <tr> <td><strong>commonStyles.ts</strong><dd><code>Add common React Native tutorial styles</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-bf0de16179ecc80a8e88e223c890dc2c73c30b4a9b7cadd62e910ca015ce342b">+667/-0</a> </td> </tr> <tr> <td><strong>Todos.tsx</strong><dd><code>Add React tutorial Todos page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-be700e4847b0a745821f156c381e583097f2083123065a45a20611c2ba1876a7">+504/-0</a> </td> </tr> <tr> <td><strong>files.tsx</strong><dd><code>Add React Native tutorial file upload</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-d116805053a943f271a3297d06d14dba39c0f5775080e67e1e9e2778c176e9da">+454/-0</a> </td> </tr> <tr> <td><strong>Files.tsx</strong><dd><code>Add React tutorial Files page</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-cdbca4ed68690df84463df7765dff52c85a60502f175c19519c8b42474e9282c">+404/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Enhance signup flow with email verification</code> </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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-ea74386f232b9ae7e7957ab4eb1f0d1d6076b338173e8b1e917369fb7f1b39bb">+359/-0</a> </td> </tr> <tr> <td><strong>TodosClient.tsx</strong><dd><code>Add Next.js Todos client component</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-96efb4db6bd61a7faee3c383e77b092eccee1a1876770c36691e7356268cfad4">+368/-0</a> </td> </tr> <tr> <td><strong>AuthProvider.tsx</strong><dd><code>Implement React AuthProvider context</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-4cc7e41420d71d448eeec4f77043e0c5bff2c606986439454dade5ffcd433e33">+174/-0</a> </td> </tr> <tr> <td><strong>AuthProvider.tsx</strong><dd><code>Implement RN AuthProvider context</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-93f9f27a35d0039a64cd6889a296d04d37542aa5a777925e61e8e60ee5a6d744">+148/-0</a> </td> </tr> <tr> <td><strong>actions.ts</strong><dd><code>Add Next.js server actions for Todos</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-76d57097940d3043c8e0ab29761767861b78fe86ab8a90a2d8209f1818131d31">+223/-0</a> </td> </tr> <tr> <td><strong>signup.tsx</strong><dd><code>Add RN signup tutorial screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-153c2f0bf6b3744bb84b95e356dd78c8771206a1b22218bc4c6f90641e4143ad">+183/-0</a> </td> </tr> <tr> <td><strong>explore.tsx</strong><dd><code>Add RN tutorial Explore screen</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-980313b6c7f75f2ecd45fc476895bea122364d000f5988e21564bf5db73d7f57">+125/-0</a> </td> </tr> <tr> <td><strong>route.ts</strong><dd><code>Add Next.js file download route</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-5c39cd02d478ad625a7cdb6df3f7b6d20a76f40488636fdd87282c66174b2bd8">+57/-0</a> </td> </tr> <tr> <td><strong>SignUp.tsx</strong><dd><code>Enhance demo signup with verification state</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-75657efa0e1c29f59692ced3cd90e9c734836977900dc64015dd5d217bb263da">+38/-3</a> </td> </tr> <tr> <td><strong>SignUpForm.tsx</strong><dd><code>Add Next.js signup client form</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9034cd412c47033a01e604a8250984aa1d1ecefc9884b79ebd7f7f3af17e3167">+89/-0</a> </td> </tr> <tr> <td><strong>SignInForm.tsx</strong><dd><code>Add Next.js signin client form</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c276aadee4507d515832909164d447c3c1a4870277d0adbd1f7e836f7c66259e">+75/-0</a> </td> </tr> <tr> <td><strong>server.tsx</strong><dd><code>Add server Nhost client for Next.js</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-9d650defc223584e4aa06ddbf1d9a97c47a5a7ec4c9589a72ac7ea5369853400">+89/-0</a> </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> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-c3b0bac088aef9f1a2d5cd2f4b51dd19fb301034668a062860a1e6a3512c15c9">+39/-0</a> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-85a3083c78e211e9eb36d741342bcbc85a1a0c375060f45c5426b560196de27f">+2/-2</a> </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> </td> </tr> <tr> <td><strong>Makefile</strong></td> <td><a href="https://github.com/nhost/nhost/pull/3469/files#diff-36623bab6fe16382fd3e61b06b9586f2b14bea7c1b492e50db14ea98935016a4">+7/-0</a> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> </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> ___
823 lines
26 KiB
Plaintext
823 lines
26 KiB
Plaintext
---
|
|
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
|
|
|
|

|
|
|
|
</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.
|
|
|
|

|
|
|
|
</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``.
|
|
|
|

|
|
|
|
</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.
|
|
|
|

|
|
|
|
</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>
|