diff --git a/.github/workflows/examples_tutorials_checks.yaml b/.github/workflows/examples_tutorials_checks.yaml new file mode 100644 index 000000000..a868b14b2 --- /dev/null +++ b/.github/workflows/examples_tutorials_checks.yaml @@ -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') diff --git a/docs/docs.json b/docs/docs.json index 91f041c9b..a4460d197 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" ] } ] diff --git a/docs/getting-started/overview.mdx b/docs/getting-started/overview.mdx index fa968ba66..f4a758e73 100644 --- a/docs/getting-started/overview.mdx +++ b/docs/getting-started/overview.mdx @@ -65,35 +65,35 @@ Follow one of your tutorials where we walk you through building a Todo Manager a Todo Manager with Nhost and NextJS Todo Manager with Nhost and React Todo Manager with Nhost and Vue Todo Manager with Nhost and SvelteKit Todo Manager with Nhost and React Native diff --git a/docs/getting-started/tutorials/nextjs.mdx b/docs/getting-started/tutorials/nextjs.mdx deleted file mode 100644 index 31aaa631f..000000000 --- a/docs/getting-started/tutorials/nextjs.mdx +++ /dev/null @@ -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. - - - -To store todos - - - -To sign in users - - - -To store attachments - - - - -## 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**. - - - - - Copy and paste the following SQL into the SQL Editor and press **Run**. - - Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs - - ```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 - ); - ``` - - - 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) - - - - -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`. - - - -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) - - -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) - - -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) - - -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) - - - -### 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`. - - - -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) - - -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) - - -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) - - - -### 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 - - - - - - - - - - - - - - - -## 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 `` and `` with the values from the project created earlier. - -```ts ./src/lib/nhost.ts -import { NhostClient } from "@nhost/nextjs"; - -export const nhost = new NhostClient({ - subdomain: "", - region: "" -}); -``` - -The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project 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 ( -
-

Todo Manager

-

powered by Nhost and React

-
-
- setEmail(e.target.value)} - /> -
-
- -
- {error &&

{error.message}

} -
-
- ) -} -``` - -### 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 ( - <> -
-
-

Add a new TODO

-
-
- - setTodoTitle(e.target.value)} - /> -
-
- - setTodoAttachment(e.target.files[0])} /> -
-
- -
-
-
-
- {(!loading && - todos.map((todo) => ( -
- completeTodo(todo.id)} - /> - {todo.file_id && ( - - openAttachment(todo)}> Open Attachment - - )} - - -
- ))) || ( -
- -
- )} -
-
- -
- -
- - ) -} -``` - -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 ( - - {session ? : } - - ) -} - -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. \ No newline at end of file diff --git a/docs/getting-started/tutorials/nextjs/1-introduction.mdx b/docs/getting-started/tutorials/nextjs/1-introduction.mdx new file mode 100644 index 000000000..646cd54dd --- /dev/null +++ b/docs/getting-started/tutorials/nextjs/1-introduction.mdx @@ -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 + + +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. + + +## Full-Stack Next.js Development with Nhost + + + +**Current** - Set up your Nhost project + + + +Route protection basics + + + +Complete auth flow + + + +CRUD operations with GraphQL + + + +File upload and management + + + +## 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 + + + + +### 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) + + + + + +### 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) + + + + + +### 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) + + + + + +## 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. + + +Keep your project subdomain and region handy - you'll need them throughout the series to connect your Next.js application to the Nhost backend. + diff --git a/docs/getting-started/tutorials/nextjs/2-protected-routes.mdx b/docs/getting-started/tutorials/nextjs/2-protected-routes.mdx new file mode 100644 index 000000000..e03566213 --- /dev/null +++ b/docs/getting-started/tutorials/nextjs/2-protected-routes.mdx @@ -0,0 +1,1364 @@ +--- +title: Protecting Routes and Content in Next.js +description: Learn how to protect routes and content in a Next.js application using Nhost authentication. +sidebarTitle: "Protecting Routes" +icon: lock +--- + +This tutorial part demonstrates how to implement robust route protection in a Next.js application using Nhost authentication. You'll build a complete authentication system with a protected `/profile` page that includes server-side rendering, client components, server actions and API routes. It also features cross-tab session synchronization, and automatic redirects. In addition, we will see how to show conditional navigation and content based on authentication status. + + +This is **Part 2** in the Full-Stack Next.js Development with Nhost series. This part builds a foundation for authentication-protected routes that you can extend to secure any part of your application. + + +## Full-Stack Next.js Development with Nhost + + + +Set up your Nhost project + + + +**Current** - Route protection basics + + + +Complete auth flow + + + +CRUD operations with GraphQL + + + +File upload and management + + + +## Prerequisites + +- An [Nhost project](/getting-started/tutorials/nextjs/1-introduction) set up +- Node.js 20+ installed +- Basic knowledge of React and Next.js + +## Step-by-Step Guide + + + + +### Create a New Next.js App + +We'll start by creating a fresh Next.js application with TypeScript support. Next.js provides server-side rendering, file-based routing, and optimized builds for modern React applications. + +```bash +npx create-next-app@15 nhost-nextjs-tutorial --typescript --eslint --app --no-tailwind --yes +cd nhost-nextjs-tutorial +npm install +``` + + + + +### Install Required Dependencies + +Install the Nhost JavaScript SDK for authentication and session management. The Nhost SDK handles authentication with built-in Next.js support for server-side rendering. + +```bash +npm install @nhost/nhost-js +``` + + + + +### Environment Configuration + +Configure your Nhost project connection by creating environment variables. This allows the app to connect to your specific Nhost backend from both server and client sides. + +Create a `.env.local` file in your project root: + +```env +NHOST_REGION= +NHOST_SUBDOMAIN= +``` + + +Replace `` and `` with the actual values from your Nhost project dashboard. + + + + + +### Create Server-Side Nhost Helper + +Create server-side utilities for handling authentication in Next.js server components and middleware. This enables server-side session access and token refresh functionality. + +```tsx src/lib/nhost/server.tsx lines +import { createServerClient, type NhostClient } from "@nhost/nhost-js"; +import { DEFAULT_SESSION_KEY, type Session } from "@nhost/nhost-js/session"; +import { cookies } from "next/headers"; +import type { NextRequest, NextResponse } from "next/server"; + +const key = DEFAULT_SESSION_KEY; + +/** + * Creates an Nhost client for use in server components. + * + * We rely on the vanilla createClient method from the Nhost JS SDK and a SessionStorage + * customized to be able to retrieve the session from cookies in Next.js server components. + */ +export async function createNhostClient(): Promise { + const cookieStore = await cookies(); + + const nhost = createServerClient({ + region: process.env["NHOST_REGION"] || "local", + subdomain: process.env["NHOST_SUBDOMAIN"] || "local", + storage: { + // storage compatible with Next.js server components + get: (): Session | null => { + const s = cookieStore.get(key)?.value || null; + if (!s) { + return null; + } + const session = JSON.parse(s) as Session; + return session; + }, + set: (value: Session) => { + cookieStore.set(key, JSON.stringify(value)); + }, + remove: () => { + cookieStore.delete(key); + }, + }, + }); + + return nhost; +} + +/** + * Middleware function to handle Nhost authentication and session management. + * + * This function is designed to be used in Next.js middleware to manage user sessions + * and refresh tokens. Refreshing the session needs to be done in the middleware + * to ensure that the session is always up-to-date an accessible by both server and client components. + * + * @param {NextRequest} request - The incoming Next.js request object + * @param {NextResponse} response - The outgoing Next.js response object + */ +export async function handleNhostMiddleware( + request: NextRequest, + response: NextResponse, +): Promise { + const nhost = createServerClient({ + region: process.env["NHOST_REGION"] || "local", + subdomain: process.env["NHOST_SUBDOMAIN"] || "local", + storage: { + // storage compatible with Next.js middleware + get: (): Session | null => { + const raw = request.cookies.get(key)?.value || null; + if (!raw) { + return null; + } + const session = JSON.parse(raw) as Session; + return session; + }, + set: (value: Session) => { + response.cookies.set({ + name: key, + value: JSON.stringify(value), + path: "/", + httpOnly: false, //if set to true we can't access it in the client + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 24 * 30, // 30 days in seconds + }); + }, + remove: () => { + response.cookies.delete(key); + }, + }, + }); + + // we only want to refresh the session if the token will + // expire in the next 60 seconds + return await nhost.refreshSession(60); +} +``` + + + + + +### Create Middleware for Route Protection + +Create Next.js middleware to handle route protection at the server level. This middleware runs before any page renders and automatically redirects unauthenticated users from protected routes. + +```tsx src/middleware.ts lines +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 = ["/"]; + +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).*)", + ], +}; +``` + + + + + +### Create the Protected Profile Page + +Create a page that displays user information. Note that the page itself doesn't need any special authentication logic - the route protection is handled entirely by the middleware from the previous step. + +```tsx src/app/profile/page.tsx lines +import { createNhostClient } from "../../lib/nhost/server"; + +export default async function Profile() { + // Create the client with async cookie access + const nhost = await createNhostClient(); + const session = nhost.getUserSession(); + + return ( +
+
+

Your Profile

+
+ +
+

User Information

+
+
+ Display Name:{" "} + {session?.user?.displayName || "Not set"} +
+
+ Email: {session?.user?.email || "Not available"} +
+
+ User ID: {session?.user?.id || "Not available"} +
+
+ Roles: {session?.user?.roles?.join(", ") || "None"} +
+
+ Email Verified: + + {session?.user?.emailVerified ? "✓ Yes" : "✗ No"} + +
+
+
+ +
+

Session Information

+
+
+            {JSON.stringify(session, null, 2)}
+          
+
+
+
+ ); +} +``` + +
+ + + +### Create a Simple Home Page + +Build a public homepage that adapts its content based on authentication status. This shows users different options depending on whether they're signed in. + +```tsx src/app/page.tsx lines +import { createNhostClient } from "../lib/nhost/server"; + +export default async function Home() { + const nhost = await createNhostClient(); + const session = nhost.getUserSession(); + + return ( +
+
+

Welcome to Nhost Next.js Demo

+
+ + {session ? ( +
+

Hello, {session.user?.displayName || session.user?.email}!

+
+ ) : ( +
+

You are not signed in.

+
+ )} +
+ ); +} +``` +
+ + + +### Create the Navigation Component + +Create a server-side navigation component that provides consistent navigation across all pages. This component adapts its links based on authentication status - showing different options for signed-in and signed-out users with server-side rendering. + +```tsx src/components/Navigation.tsx lines +import Link from "next/link"; +import { createNhostClient } from "../lib/nhost/server"; + +export default async function Navigation() { + const nhost = await createNhostClient(); + const session = nhost.getUserSession(); + + return ( + + ); +} +``` + + + + + +### Update the Root Layout + +Configure the Next.js root layout to include the navigation and page content. + +```tsx src/app/layout.tsx lines +import type { Metadata } from "next"; +import "./globals.css"; +import Navigation from "../components/Navigation"; + +export const metadata: Metadata = { + title: "Nhost Next.js Tutorial", + description: "Next.js tutorial with Nhost authentication", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + +
{children}
+ + + ); +} +``` +
+ + + +### Add Application Styles + +Replace the contents of the file `src/app/global.css` with the following styles to provide a clean and modern look for the application. This file will be used across the rest of series. + +```css src/app/globals.css lines +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; +} + +#root { + width: 100%; + min-height: 100vh; + display: block; + margin: 0; + padding: 0; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +input, +textarea { + width: 100%; + padding: 0.875rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + font-size: 0.875rem; + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.05); + color: white; + box-sizing: border-box; + font-family: inherit; +} + +input:focus, +textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + background: rgba(255, 255, 255, 0.08); +} + +input::placeholder, +textarea::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +textarea { + resize: vertical; + min-height: 4rem; +} + +label { + display: block; + margin: 0 0 0.5rem 0; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + font-size: 0.875rem; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Global Layout */ +.app-content { + padding: 0 2rem 2rem; + max-width: 800px; + margin: 0 auto; +} + +.page-center { + text-align: center; + padding: 2rem; +} + +.page-header { + margin-bottom: 2rem; +} + +.page-title { + font-weight: 700; + margin: 0; + display: flex; + align-items: center; + gap: 1rem; +} + +.margin-bottom { + margin-bottom: 1rem; +} + +.margin-top { + margin-top: 1rem; +} + +.container { + width: 800px; + max-width: calc(100vw - 4rem); + min-width: 320px; + margin: 0 auto; + padding: 2rem; + box-sizing: border-box; + position: relative; +} + +/* Status Messages */ +.success-message { + padding: 1rem; + background-color: #d4edda; + color: #155724; + border-radius: 8px; + margin-bottom: 1rem; +} + +.error-message { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 12px; + padding: 1rem 1.5rem; + margin: 1rem 0; +} + +.help-text { + color: #666; +} + +.verification-status { + color: #28a745; + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 1rem; +} + +.verification-status.error { + color: #dc3545; + font-size: 1.1rem; +} + +/* Email Verification Status */ +.email-verified { + color: #10b981; + font-weight: bold; + margin-left: 0.5rem; +} + +.email-unverified { + color: #ef4444; + font-weight: bold; + margin-left: 0.5rem; +} + +/* Debug Info */ +.debug-panel { + margin-bottom: 1rem; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 8px; + text-align: left; + max-height: 200px; + overflow: auto; +} + +.debug-title { + font-weight: bold; + margin-bottom: 0.5rem; +} + +.debug-item { + margin-bottom: 0.25rem; +} + +.debug-key { + font-family: monospace; + color: #007bff; +} + +.debug-value { + font-family: monospace; +} + +/* Session Display */ +.session-display { + font-size: 0.75rem; + overflow: auto; + margin: 0; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +/* Loading Spinner */ +.spinner-verify { + width: 32px; + height: 32px; + border: 3px solid #f3f3f3; + border-top: 3px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto; +} + +/* Navigation */ +.navigation { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: sticky; + top: 0; + z-index: 100; + margin-bottom: 2rem; +} + +.nav-container { + max-width: 800px; + margin: 0 auto; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-logo { + font-size: 1.25rem; + font-weight: 700; + color: white; + text-decoration: none; + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.nav-logo:hover { + opacity: 0.8; +} + +.nav-links { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.nav-link { + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + font-weight: 500; + font-size: 0.875rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + transition: all 0.2s ease; + border: none; + background: none; + cursor: pointer; + font-family: inherit; +} + +.nav-link:hover { + color: white; + background: rgba(255, 255, 255, 0.1); +} + +.nav-button { + color: #ef4444; +} + +.nav-button:hover { + background: rgba(239, 68, 68, 0.2); +} + +/* Buttons */ +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-width: 120px; +} + +.btn-primary { + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); + color: white; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); +} + +.btn-cancel { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.btn-cancel:hover { + background: rgba(239, 68, 68, 0.2); +} + +/* Loading State */ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + padding: 4rem 2rem; +} + +.loading-content { + display: flex; + align-items: center; + gap: 1rem; +} + +.spinner { + width: 2rem; + height: 2rem; + border: 3px solid rgba(59, 130, 246, 0.3); + border-top: 3px solid #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.loading-text { + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 4rem 2rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; +} + +.empty-icon { + width: 4rem; + height: 4rem; + color: rgba(255, 255, 255, 0.4); + margin: 0 auto 1rem; +} + +.empty-title { + font-size: 1.25rem; + font-weight: 600; + color: white; + margin: 0 0 0.5rem 0; +} + +.empty-description { + color: rgba(255, 255, 255, 0.6); + margin: 0; +} + +/* Forms */ +.form-card { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 2rem; + margin-bottom: 2rem; + width: 100%; + box-sizing: border-box; +} + +.form-title { + font-size: 1.5rem; + font-weight: 600; + color: white; + margin: 0 0 1.5rem 0; +} + +.form-fields { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.field-group { + display: flex; + flex-direction: column; +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +/* Auth Pages */ +.auth-form { + max-width: 400px; +} + +.auth-form-field { + margin-bottom: 1rem; +} + +.auth-input { + width: 100%; + padding: 0.5rem; + margin-top: 0.25rem; +} + +.auth-error { + color: red; + margin-bottom: 1rem; + padding: 0.5rem; + background-color: #fee; + border-radius: 4px; +} + +.auth-button { + width: 100%; + padding: 0.75rem; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.auth-button:disabled { + cursor: not-allowed; +} + +.auth-button.primary { + background-color: #28a745; +} + +.auth-button.secondary { + background-color: #007bff; +} + +.auth-links { + margin-top: 1rem; +} + +/* Todos */ + +.todo-form { + width: 100%; +} + +/* Todo List */ +.todos-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.todo-card { + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + transition: all 0.2s ease; + overflow: hidden; + width: 100%; + box-sizing: border-box; +} + +.todo-card:hover { + border-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.todo-card.completed { + opacity: 0.7; +} + +/* Todo Content */ +.todo-content { + padding: 1rem 1.5rem; +} + +.todo-edit { + padding: 1.5rem; + min-height: 200px; +} + +.edit-fields { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.edit-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; +} + +.todo-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.todo-title-btn { + background: none; + border: none; + padding: 0; + text-align: left; + font-size: 1.25rem; + font-weight: 600; + color: white; + cursor: pointer; + transition: color 0.2s ease; + flex: 1; + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: calc(100% - 140px); +} + +.todo-title-btn:hover { + color: #3b82f6; +} + +.todo-title-btn.completed { + text-decoration: line-through; + color: rgba(255, 255, 255, 0.5); +} + +.todo-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; + min-width: 132px; + justify-content: flex-end; +} + +/* Action Buttons */ +.action-btn { + width: 40px; + height: 40px; + border: none; + border-radius: 6px; + cursor: pointer; + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s ease; + -webkit-text-fill-color: currentColor; +} + +.action-btn-complete { + color: #10b981; + font-size: 20px; +} + +.action-btn-complete:hover { + background: rgba(16, 185, 129, 0.2); + color: #34d399; +} + +.action-btn-edit { + color: #3b82f6; +} + +.action-btn-edit:hover { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; +} + +.action-btn-delete { + color: #ef4444; +} + +.action-btn-delete:hover { + background: rgba(239, 68, 68, 0.2); + color: #f87171; +} + +/* Add Todo Button */ +.add-todo-btn { + width: 36px; + height: 36px; + border: none; + border-radius: 6px; + cursor: pointer; + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 18px; + font-weight: normal; + -webkit-text-fill-color: white; + transition: all 0.2s ease; +} + +.add-todo-btn:hover { + transform: scale(1.1); + box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); +} + +/* Todo Details */ +.todo-details { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.description { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +.description p { + margin: 0; + color: rgba(255, 255, 255, 0.8); + line-height: 1.6; +} + +.description.completed p { + text-decoration: line-through; + color: rgba(255, 255, 255, 0.5); +} + +.todo-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.meta-dates { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.meta-item { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); +} + +.completion-badge { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + color: #10b981; + font-weight: 500; +} + +.completion-icon { + width: 0.875rem; + height: 0.875rem; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .nav-container { + padding: 1rem; + flex-direction: column; + gap: 1rem; + } + + .nav-links { + gap: 1rem; + flex-wrap: wrap; + justify-content: center; + } + + .container { + padding: 1rem; + } + + .form-actions { + flex-direction: column; + } + + .edit-actions { + flex-direction: column; + } + + .todo-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .todo-actions { + align-self: stretch; + justify-content: center; + } + + .meta-dates { + flex-direction: column; + gap: 0.25rem; + } + + .todo-meta { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } +} + +/* File Upload */ +.file-upload-btn { + min-height: 120px; + flex-direction: column; + gap: 0.5rem; + width: 100%; + border: 2px dashed rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.file-upload-btn:hover { + border-color: rgba(255, 255, 255, 0.5); + background-color: rgba(255, 255, 255, 0.05); +} + +.file-upload-info { + margin-top: 0.5rem; + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.8); +} + +/* File Table */ +.file-table { + width: 100%; + border-collapse: collapse; +} + +.file-table th { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + font-size: 0.875rem; +} + +.file-table th:last-child { + text-align: center; +} + +.file-table td { + padding: 0.75rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.file-table tr:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +.file-name { + color: white; + font-weight: 500; +} + +.file-meta { + color: rgba(255, 255, 255, 0.6); + font-size: 0.875rem; +} + +.file-actions { + display: flex; + gap: 0.5rem; + justify-content: center; +} + +/* Responsive File Table */ +@media (max-width: 768px) { + .file-table { + font-size: 0.875rem; + } + + .file-table th, + .file-table td { + padding: 0.5rem; + } + + .file-actions { + flex-direction: column; + gap: 0.25rem; + } +} +``` + + + + + +### Run and test the Application + +Start the development server to test your route protection implementation: + +```bash +npm run dev +``` + +Things to try out: + +1. Try navigating to `/profile` - you should be redirected to the homepage `/` since you're not authenticated. +2. Because you are not signed in, the navigation bar should only show the "Home" link and the placeholder for signin/signup links. +3. On the homepage, you should see a message indicating that you are not signed in. + +After we complete the next tutorial on user authentication, you will be able to sign in and access the protected `/profile` page and see how the navigation bar and homepage updates accordingly. + + +
+ +## How It Works + +1. **Server-Side Helpers**: Utilities for handling authentication in Next.js server components and middleware +2. **Middleware Route Protection**: Next.js middleware runs before any page renders, automatically redirecting unauthenticated users from protected routes and refreshing tokens +3. **AuthProvider**: Client-side provider that manages authentication state using Nhost's client with cookie-based storage for server/client synchronization +4. **Protected Pages**: Server components can assume authentication since middleware handles protection, focusing purely on rendering authenticated content +5. **Navigation**: Server-side navigation component that adapts its links based on authentication status +6. **Automatic Redirects**: All route protection and redirects are handled at the middleware level for optimal performance and security + +## Key Features Demonstrated + + + +Routes are protected at the middleware level, running before any page renders to provide the highest level of security and performance by preventing unauthorized access before components load. + + + +Server-side authentication checks eliminate the need for loading spinners during authentication verification, providing instant feedback to users. + + + +Users are automatically redirected based on their authentication status using Next.js server-side redirects, ensuring proper navigation flow before any client-side rendering. + + + +Authentication state is synchronized across multiple browser tabs using Nhost's session storage events with Next.js router refresh for server-side state updates. + + + +Complete user session and profile information is shared between server and client using cookie-based storage, enabling seamless SSR and client-side functionality. + + + +Authentication-dependent content is rendered server-side for improved SEO, performance, and security compared to client-only approaches. + + + +Middleware automatically refreshes authentication tokens before they expire, ensuring seamless user experience without manual token management. + + diff --git a/docs/getting-started/tutorials/nextjs/3-user-authentication.mdx b/docs/getting-started/tutorials/nextjs/3-user-authentication.mdx new file mode 100644 index 000000000..646c1b50a --- /dev/null +++ b/docs/getting-started/tutorials/nextjs/3-user-authentication.mdx @@ -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. + + +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. + + +## Full-Stack Next.js Development with Nhost + + + +Set up your Nhost project + + + +Route protection basics + + + +**Current** - Complete auth flow + + + +CRUD operations with GraphQL + + + +File upload and management + + + +## 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 + + + + +### 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. + + + + +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 ( +
+

Sign In

+ + +
+

+ Don't have an account? Sign Up +

+
+
+ ); +} +``` + +
+ + +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(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 ( +
+
+ + +
+ +
+ + +
+ + {error &&
{error}
} + + +
+ ); +} +``` + +
+ + +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; + return { + error: `An error occurred during sign in: ${error.message}`, + }; + } +} +``` + + +
+ +
+ + + +### 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. + + + + +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 ( +
+

Check Your Email

+
+

+ We've sent a verification link to {email} +

+

+ Please check your email and click the verification link to activate + your account. +

+
+

+ Back to Sign In +

+
+ ); + } + + return ( +
+

Sign Up

+ + +
+

+ Already have an account? Sign In +

+
+
+ ); +} +``` + +
+ + +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(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 ( +
+
+ + +
+ +
+ + +
+ +
+ + + Minimum 8 characters +
+ + {error &&
{error}
} + + +
+ ); +} +``` + +
+ + +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; + return { + error: `An error occurred during sign up: ${error.message}`, + }; + } +} +``` + + +
+ +
+ + + +### 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. + + + + +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; + 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), + ); + } +} +``` + + + + +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>; +}) { + 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 ( +
+

Email Verification

+ +
+

Verification failed

+

{message}

+ + {urlParams.length > 0 && ( +
+

URL Parameters:

+ {urlParams.map(([key, value]) => ( +
+ {key}:{" "} + {value} +
+ ))} +
+ )} + + + Back to Sign In + +
+
+ ); +} +``` + +
+
+ + +**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. + + +
+ + + +### 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. + + + + +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 ( + + ); +} +``` + + + + +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("/"); +} +``` + + + + + + + + +### 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 ( + + ); +} +``` + + + + + +### 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).*)", + ], +}; +``` + + + + + +### 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. + + +
+ +## Key Features Demonstrated + + + +Full authentication flow using Next.js App Router with server components and server actions for secure, server-side processing. + + + +Custom `/verify` Route Handler that securely processes email verification tokens server-side with proper error handling. + + + +Clear separation between server components for rendering and client components for interactivity, following Next.js best practices. + + + +Comprehensive error handling with URL-based error states and dedicated error pages for different failure scenarios. + + + +Server-side session handling with sign out functionality using server actions and proper state management. + + diff --git a/docs/getting-started/tutorials/nextjs/4-graphql-operations.mdx b/docs/getting-started/tutorials/nextjs/4-graphql-operations.mdx new file mode 100644 index 000000000..c0aa7f00b --- /dev/null +++ b/docs/getting-started/tutorials/nextjs/4-graphql-operations.mdx @@ -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. + + +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. + + +## Full-Stack Next.js Development with Nhost + + + +Set up your Nhost project + + + +Route protection basics + + + +Complete auth flow + + + +**Current** - CRUD operations with GraphQL + + + +File upload and management + + + +## 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 + + + + +### 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: + + + + + +```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(); + +``` + + + + + ![Database SQL Editor](/images/tutorials/todos/1.png) + + + + + + +Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs + + + + + + +### 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**. + + + + + +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) + + + + +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) + + + + +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) + + + + +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) + + + + + + + + + +### 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. + + + + +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({ + 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 ; +} +``` + + + + +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(initialTodos); + const [error, setError] = useState(initialError); + const [newTodoTitle, setNewTodoTitle] = useState(""); + const [newTodoDetails, setNewTodoDetails] = useState(""); + const [editingTodo, setEditingTodo] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + const [expandedTodos, setExpandedTodos] = useState>(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>, + ) => { + 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 ( +
+
+

+ My Todos + {!showAddForm && ( + + )} +

+
+ + {error && ( +
+ Error: {error} +
+ )} + + {showAddForm && ( +
+
+

Add New Todo

+
+
+ + setNewTodoTitle(e.target.value)} + placeholder="What needs to be done?" + required + disabled={isLoading} + /> +
+
+ + +
+
+ + +
+
+
+
+ {/if} + + {#if !showAddForm} + {#if loading} +
+
+
+ Loading todos... +
+
+ {:else} +
+ {#if todos.length === 0} +
+ +

No todos yet

+

+ Create your first todo to get started! +

+
+ {:else} + {#each todos as todo (todo.id)} +
+ {#if editingTodo?.id === todo.id} +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ {:else} +
+
+ +
+ + + +
+
+ + {#if expandedTodos.has(todo.id)} +
+ {#if todo.details} +
+

{todo.details}

+
+ {/if} + +
+
+ + Created: {new Date(todo.created_at).toLocaleString()} + + + Updated: {new Date(todo.updated_at).toLocaleString()} + +
+ {#if todo.completed} +
+ + Completed +
+ {/if} +
+
+ {/if} +
+ {/if} +
+ {/each} + {/if} +
+ {/if} + {/if} +
+{/if} +``` + +
+ + + +### 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} + + +
+ + +
+ {#if children} + {@render children()} + {/if} +
+
+``` + +
+ + + +### 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 + + +
+ +## Key Features Implemented + + + +Properly designed todos table with constraints, indexes, and automatic timestamp updates for optimal performance. + + + +Auto-generated GraphQL API with queries and mutations for full CRUD operations on todos. + + + +Comprehensive permissions ensuring users can only access their own todos through all GraphQL operations. + + + +Complete Create, Read, Update, Delete functionality with proper error handling and user feedback. + + + +Expandable todo items, inline editing, completion status, and detailed timestamps using SvelteKit's reactive UI patterns. + + diff --git a/docs/getting-started/tutorials/svelte/5-file-uploads.mdx b/docs/getting-started/tutorials/svelte/5-file-uploads.mdx new file mode 100644 index 000000000..a96f74d79 --- /dev/null +++ b/docs/getting-started/tutorials/svelte/5-file-uploads.mdx @@ -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. + + +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. + + +## Full-Stack SvelteKit Development with Nhost + + + +Set up your Nhost project + + + +Route protection basics + + + +Complete auth flow + + + +CRUD operations with GraphQL + + + +**Current** - File upload and management + + + +## 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 + + + + +### 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) + + + + + +### 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. + + + + + +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) + + + + + +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) + + + + + +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) + + + + + + +You can read more about storage permissions [here](/products/storage/overview#permissions) + + + + + + +### Create the File Upload Component + +Now let's implement the SvelteKit page component for file upload functionality. + +```svelte src/routes/files/+page.svelte lines + + +{#if !$auth.session} +
+

Please sign in to access file uploads.

+
+{:else} +
+ + +
+

Upload a File

+ +
+ + +
+ + {#if error} +
{error}
+ {/if} + + {#if uploadResult} +
File uploaded successfully!
+ {/if} + + +
+ +
+

Your Files

+ + {#if deleteStatus} +
+ {deleteStatus.message} +
+ {/if} + + {#if isFetching} +
+
+
+ Loading files... +
+
+ {:else if files.length === 0} +
+ +

No files yet

+

Upload your first file to get started!

+
+ {:else} +
+ + + + + + + + + + + {#each files as file (file.id)} + + + + + + + {/each} + +
NameTypeSizeActions
{file.name}{file.mimeType}{formatFileSize(file.size || 0)} +
+ + +
+
+
+ {/if} +
+
+{/if} +``` + +
+ + + +### 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} + + +
+ + +
+ {#if children} + {@render children()} + {/if} +
+
+``` + +
+ + + +### 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 + + +
+ +## Key Features Implemented + + + +Dedicated personal storage bucket with proper configuration for user file isolation. + + + +User-friendly upload interface with file selection, preview, and progress feedback using SvelteKit's reactive patterns. + + + +Complete file listing with metadata, viewing capabilities, and deletion functionality. + + + +Intelligent handling of different file types with appropriate viewing/download behavior. + + + +Comprehensive error handling with user-friendly messages for upload and management operations using Svelte stores. + + diff --git a/docs/getting-started/tutorials/sveltekit.mdx b/docs/getting-started/tutorials/sveltekit.mdx deleted file mode 100644 index db87d12cf..000000000 --- a/docs/getting-started/tutorials/sveltekit.mdx +++ /dev/null @@ -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. - - - -To store todos - - - -To sign in users - - - -To store attachments - - - - -## 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**. - - - - - Copy and paste the following SQL into the SQL Editor and press **Run**. - - Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs - - ```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 - ); - ``` - - - 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) - - - - -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`. - - - -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) - - -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) - - -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) - - -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) - - - -### 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`. - - - -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) - - -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) - - -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) - - - -### 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 - - - - - - - - - - - - - - - -## 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 `` and `` with the values from the project created earlier. - -```ts ./src/lib/nhost.ts -import { NhostClient } from "@nhost/react"; - -export const nhost = new NhostClient({ - subdomain: "", - region: "" -}); -``` - -The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project 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 ( -
-

Todo Manager

-

powered by Nhost and React

-
-
- setEmail(e.target.value)} - /> -
-
- -
- {error &&

{error.message}

} -
-
- ) -} -``` - -### 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 ( - <> -
-
-

Add a new TODO

-
-
- - setTodoTitle(e.target.value)} - /> -
-
- - setTodoAttachment(e.target.files[0])} /> -
-
- -
-
-
-
- {(!loading && - todos.map((todo) => ( -
- completeTodo(todo.id)} - /> - {todo.file_id && ( - - openAttachment(todo)}> Open Attachment - - )} - - -
- ))) || ( -
- -
- )} -
-
- -
- -
- - ) -} -``` - -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 ( - - {session ? : } - - ) -} - -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. - diff --git a/docs/getting-started/tutorials/vue.mdx b/docs/getting-started/tutorials/vue.mdx deleted file mode 100644 index ce321d592..000000000 --- a/docs/getting-started/tutorials/vue.mdx +++ /dev/null @@ -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. - - - -To store todos - - - -To sign in users - - - -To store attachments - - - - - -## 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**. - - - - Copy and paste the following SQL into the SQL Editor and press **Run**. - - Please make sure to enable **Track this** so that the new table `todos` is available through the auto-generated APIs - - ```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 - ); - ``` - - - 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) - - - - -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`. - - - -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) - - -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) - - -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) - - -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) - - - -### 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`. - - - -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) - - -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) - - -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) - - - -### 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 - - - - - - - - - - - - - - - - -## 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 `` and `` 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: "", - region: "" -}); -``` - -The project's `subdomain` and `region` can be found in the Nhost Dashboard under **Project 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 - - - -``` - -### 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 - - - -``` - -With both `SignIn` and `Todos` in place, update `./src/App.vue` to use the new components: - -```js ./src/App.vue - - - -``` - -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. - diff --git a/docs/getting-started/tutorials/vue/1-introduction.mdx b/docs/getting-started/tutorials/vue/1-introduction.mdx new file mode 100644 index 000000000..98b23d1e0 --- /dev/null +++ b/docs/getting-started/tutorials/vue/1-introduction.mdx @@ -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 + + +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. + + +## Full-Stack Vue Development with Nhost + + + +**Current** - Set up your Nhost project + + + +Route protection basics + + + +Complete auth flow + + + +CRUD operations with GraphQL + + + +File upload and management + + + +## 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 + + + + +### 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) + + + + + +### 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) + + + + + +### 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) + + + + + +## 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. + + +Keep your project subdomain and region handy - you'll need them throughout the series to connect your Vue application to the Nhost backend. + diff --git a/docs/getting-started/tutorials/vue/2-protected-routes.mdx b/docs/getting-started/tutorials/vue/2-protected-routes.mdx new file mode 100644 index 000000000..9888b7f46 --- /dev/null +++ b/docs/getting-started/tutorials/vue/2-protected-routes.mdx @@ -0,0 +1,1368 @@ +--- +title: Protecting Routes and Content in Vue +description: Learn how to protect routes and content in a Vue application using Nhost authentication. +sidebarTitle: "Protecting Routes" +icon: lock +--- + +This tutorial part demonstrates how to implement robust route protection in a Vue application using Nhost authentication. You'll build a complete authentication system with a protected `/profile` page that includes cross-tab session synchronization and automatic redirects. In addition, we will see how to show conditional navigation and content based on authentication status. + + +This is **Part 2** in the Full-Stack Vue Development with Nhost series. This part builds a foundation for authentication-protected routes that you can extend to secure any part of your application. + + +## Full-Stack Vue Development with Nhost + + + +Set up your Nhost project + + + +**Current** - Route protection basics + + + +Complete auth flow + + + +CRUD operations with GraphQL + + + +File upload and management + + + +## Prerequisites + +- An [Nhost project](/getting-started/tutorials/vue/1-introduction) set up +- Node.js 20+ installed +- Basic knowledge of Vue and Vue Router + +## Step-by-Step Guide + + + + +### Create a New Vue App + +We'll start by creating a fresh Vue application using Vite with TypeScript support. Vite provides fast development server and optimized builds for modern Vue applications. + +```bash +npm create vue@latest nhost-vue-tutorial -- --typescript --router +cd nhost-vue-tutorial +npm install +``` + + + + +### Install Required Dependencies + +Install the Nhost JavaScript SDK. The Nhost SDK handles authentication, while Vue Router (already included) enables protected route navigation. + +```bash +npm install @nhost/nhost-js +``` + + + + +### Environment Configuration + +Configure your Nhost project connection by creating environment variables. This allows the app to connect to your specific Nhost backend. + +Create a `.env` file in your project root: + +```env +VITE_NHOST_REGION= +VITE_NHOST_SUBDOMAIN= +``` + + +Replace `` and `` with the actual values from your Nhost project dashboard. + + + + + +### Create the Nhost Auth Composable + +Build the core authentication composable that manages user sessions across your Vue application. This composable provides reactive authentication state and handles cross-tab synchronization. + +```typescript src/lib/nhost/auth.ts lines +import { createClient } from "@nhost/nhost-js"; +import type { Session } from "@nhost/nhost-js/auth"; +import { computed, reactive } from "vue"; + +// Global reactive state +const authState = reactive({ + user: null as Session["user"] | null, + session: null as Session | null, + isLoading: true, +}); + +// Create the nhost client +const nhost = createClient({ + region: (import.meta.env["VITE_NHOST_REGION"] as string) || "local", + subdomain: (import.meta.env["VITE_NHOST_SUBDOMAIN"] as string) || "local", +}); + +// Subscription cleanup function +let unsubscribe: (() => void) | null = null; +let lastRefreshTokenIdRef: string | null = null; +let isInitialized = false; + +/** + * Handles session reload when refresh token changes. + * This detects when the session has been updated from other tabs. + * + * @param currentRefreshTokenId - The current refresh token ID to compare against stored value + */ +const reloadSession = (currentRefreshTokenId: string | null) => { + if (currentRefreshTokenId !== lastRefreshTokenIdRef) { + lastRefreshTokenIdRef = currentRefreshTokenId; + + // Update local authentication state to match current session + const currentSession = nhost.getUserSession(); + authState.user = currentSession?.user || null; + authState.session = currentSession; + } +}; + +// Initialize auth state +const initializeAuth = () => { + if (isInitialized) return; + + authState.isLoading = true; + + // Set initial values + const currentSession = nhost.getUserSession(); + authState.user = currentSession?.user || null; + authState.session = currentSession; + lastRefreshTokenIdRef = currentSession?.refreshTokenId ?? null; + authState.isLoading = false; + + // Subscribe to session changes from other browser tabs + // This enables real-time synchronization when user signs in/out in another tab + unsubscribe = nhost.sessionStorage.onChange((currentSession) => { + reloadSession(currentSession?.refreshTokenId ?? null); + }); + + // Handle session changes from page focus events (for additional session consistency) + const checkSessionOnFocus = () => { + reloadSession(nhost.getUserSession()?.refreshTokenId ?? null); + }; + + // Monitor page visibility changes (tab switching, window minimizing) + document.addEventListener("visibilitychange", () => { + if (!document.hidden) { + checkSessionOnFocus(); + } + }); + + // Monitor window focus events (clicking back into the browser window) + window.addEventListener("focus", checkSessionOnFocus); + + isInitialized = true; +}; + +// Cleanup function +const cleanup = () => { + if (unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + isInitialized = false; +}; + +/** + * Vue composable for authentication state and operations. + * + * Provides reactive access to current user session, authentication state, and Nhost client. + * Handles cross-tab session synchronization and automatic state updates. + * + * @returns Object containing reactive authentication state and Nhost client + * + * @example + * ```vue + * + * + * + * ``` + */ +export function useAuth() { + // Initialize auth if not already done + if (!isInitialized && typeof window !== "undefined") { + initializeAuth(); + } + + return { + user: computed(() => authState.user), + session: computed(() => authState.session), + isLoading: computed(() => authState.isLoading), + isAuthenticated: computed(() => !!authState.session), + nhost, + }; +} + +// Initialize auth immediately (for SSR compatibility) +if (typeof window !== "undefined") { + initializeAuth(); +} + +// Cleanup on window unload +if (typeof window !== "undefined") { + window.addEventListener("beforeunload", cleanup); +} +``` + + + + + +### Create the Profile Page + +Create a page that displays user information. Note that the page itself doesn't need any special authentication logic - the route protection is handled entirely by the Vue Router navigation guard we set up in the previous step. + +```vue src/views/ProfileView.vue lines + + + +``` + + + + + +### Create a Simple Home Page + +Build a public homepage that adapts its content based on authentication status. This shows users different options depending on whether they're signed in. + +```vue src/views/HomeView.vue lines + + + +``` + + + + +### Create the Navigation Component + +Create a reusable navigation component that provides consistent navigation across all pages. This component adapts its links based on authentication status - showing different options for signed-in and signed-out users. + +```vue src/components/Navigation.vue lines + + + +``` + + + + +### Configure Protected Routes with Vue Router + +Set up Vue Router to handle route protection using navigation guards. This approach provides centralized route protection and automatic redirects for unauthenticated users. + +```typescript src/router/index.ts lines +import { createRouter, createWebHistory } from "vue-router"; +import { useAuth } from "../lib/nhost/auth"; +import HomeView from "../views/HomeView.vue"; +import ProfileView from "../views/ProfileView.vue"; + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: "/", + name: "home", + component: HomeView, + }, + { + 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; +``` + + + + + +### Update the Main App Component + +Configure the application's main component to include the navigation bar and router view. Since we've configured route protection in the router itself, the App component mainly needs to provide the layout structure. + +```vue src/App.vue lines + + + +``` + + + + +### Add Application Styles + +Replace the contents of the file `src/assets/main.css` with the following styles to provide a clean and modern look for the application. This file will be used across the rest of series. + +```css src/assets/main.css lines +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +#app { + width: 100%; + min-height: 100vh; + display: block; + margin: 0; + padding: 0; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} + +a:hover { + color: #535bf2; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + border-color: #646cff; +} + +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +input, +textarea { + width: 100%; + padding: 0.875rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + font-size: 0.875rem; + transition: all 0.2s ease; + background: rgba(255, 255, 255, 0.05); + color: white; + box-sizing: border-box; + font-family: inherit; +} + +input:focus, +textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + background: rgba(255, 255, 255, 0.08); +} + +input::placeholder, +textarea::placeholder { + color: rgba(255, 255, 255, 0.5); +} + +textarea { + resize: vertical; + min-height: 4rem; +} + +label { + display: block; + margin: 0 0 0.5rem 0; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + font-size: 0.875rem; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Global Layout */ +.app-content { + padding: 0 2rem 2rem; + max-width: 800px; + margin: 0 auto; +} + +.page-center { + text-align: center; + padding: 2rem; +} + +.page-header { + margin-bottom: 2rem; +} + +.page-title { + font-weight: 700; + margin: 0; + display: flex; + align-items: center; + gap: 1rem; +} + +.margin-bottom { + margin-bottom: 1rem; +} + +.margin-top { + margin-top: 1rem; +} + +.container { + width: 800px; + max-width: calc(100vw - 4rem); + min-width: 320px; + margin: 0 auto; + padding: 2rem; + box-sizing: border-box; + position: relative; +} + +/* Status Messages */ +.success-message { + padding: 1rem; + background-color: #d4edda; + color: #155724; + border-radius: 8px; + margin-bottom: 1rem; +} + +.error-message { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 12px; + padding: 1rem 1.5rem; + margin: 1rem 0; +} + +.help-text { + color: #666; +} + +.verification-status { + color: #28a745; + font-size: 1.2rem; + font-weight: bold; + margin-bottom: 1rem; +} + +.verification-status.error { + color: #dc3545; + font-size: 1.1rem; +} + +/* Email Verification Status */ +.email-verified { + color: #10b981; + font-weight: bold; + margin-left: 0.5rem; +} + +.email-unverified { + color: #ef4444; + font-weight: bold; + margin-left: 0.5rem; +} + +/* Debug Info */ +.debug-panel { + margin-bottom: 1rem; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 8px; + text-align: left; + max-height: 200px; + overflow: auto; +} + +.debug-title { + font-weight: bold; + margin-bottom: 0.5rem; +} + +.debug-item { + margin-bottom: 0.25rem; +} + +.debug-key { + font-family: monospace; + color: #007bff; +} + +.debug-value { + font-family: monospace; +} + +/* Session Display */ +.session-display { + font-size: 0.75rem; + overflow: auto; + margin: 0; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +/* Loading Spinner */ +.spinner-verify { + width: 32px; + height: 32px; + border: 3px solid #f3f3f3; + border-top: 3px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto; +} + +/* Navigation */ +.navigation { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + position: sticky; + top: 0; + z-index: 100; + margin-bottom: 2rem; +} + +.nav-container { + max-width: 800px; + margin: 0 auto; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-logo { + font-size: 1.25rem; + font-weight: 700; + color: white; + text-decoration: none; + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.nav-logo:hover { + opacity: 0.8; +} + +.nav-links { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.nav-link { + color: rgba(255, 255, 255, 0.8); + text-decoration: none; + font-weight: 500; + font-size: 0.875rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + transition: all 0.2s ease; + border: none; + background: none; + cursor: pointer; + font-family: inherit; +} + +.nav-link:hover { + color: white; + background: rgba(255, 255, 255, 0.1); +} + +.nav-button { + color: #ef4444; +} + +.nav-button:hover { + background: rgba(239, 68, 68, 0.2); +} + +/* Buttons */ +.btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.875rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-width: 120px; +} + +.btn-primary { + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); + color: white; +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); +} + +.btn-secondary { + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.btn-secondary:hover { + background: rgba(255, 255, 255, 0.2); +} + +.btn-cancel { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.btn-cancel:hover { + background: rgba(239, 68, 68, 0.2); +} + +/* Loading State */ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + padding: 4rem 2rem; +} + +.loading-content { + display: flex; + align-items: center; + gap: 1rem; +} + +.spinner { + width: 2rem; + height: 2rem; + border: 3px solid rgba(59, 130, 246, 0.3); + border-top: 3px solid #3b82f6; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +.loading-text { + color: rgba(255, 255, 255, 0.7); + font-size: 0.875rem; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 4rem 2rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; +} + +.empty-icon { + width: 4rem; + height: 4rem; + color: rgba(255, 255, 255, 0.4); + margin: 0 auto 1rem; +} + +.empty-title { + font-size: 1.25rem; + font-weight: 600; + color: white; + margin: 0 0 0.5rem 0; +} + +.empty-description { + color: rgba(255, 255, 255, 0.6); + margin: 0; +} + +/* Forms */ +.form-card { + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + padding: 2rem; + margin-bottom: 2rem; + width: 100%; + box-sizing: border-box; +} + +.form-title { + font-size: 1.5rem; + font-weight: 600; + color: white; + margin: 0 0 1.5rem 0; +} + +.form-fields { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.field-group { + display: flex; + flex-direction: column; +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; +} + +/* Auth Pages */ +.auth-form { + max-width: 400px; +} + +.auth-form-field { + margin-bottom: 1rem; +} + +.auth-input { + width: 100%; + padding: 0.5rem; + margin-top: 0.25rem; +} + +.auth-error { + color: red; + margin-bottom: 1rem; + padding: 0.5rem; + background-color: #fee; + border-radius: 4px; +} + +.auth-button { + width: 100%; + padding: 0.75rem; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.auth-button:disabled { + cursor: not-allowed; +} + +.auth-button.primary { + background-color: #28a745; +} + +.auth-button.secondary { + background-color: #007bff; +} + +.auth-links { + margin-top: 1rem; +} + +/* Todos */ + +.todo-form { + width: 100%; +} + +/* Todo List */ +.todos-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.todo-card { + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + transition: all 0.2s ease; + overflow: hidden; + width: 100%; + box-sizing: border-box; +} + +.todo-card:hover { + border-color: rgba(255, 255, 255, 0.2); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); +} + +.todo-card.completed { + opacity: 0.7; +} + +/* Todo Content */ +.todo-content { + padding: 1rem 1.5rem; +} + +.todo-edit { + padding: 1.5rem; + min-height: 200px; +} + +.edit-fields { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.edit-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; +} + +.todo-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.todo-title-btn { + background: none; + border: none; + padding: 0; + text-align: left; + font-size: 1.25rem; + font-weight: 600; + color: white; + cursor: pointer; + transition: color 0.2s ease; + flex: 1; + line-height: 1.4; + word-wrap: break-word; + overflow-wrap: break-word; + max-width: calc(100% - 140px); +} + +.todo-title-btn:hover { + color: #3b82f6; +} + +.todo-title-btn.completed { + text-decoration: line-through; + color: rgba(255, 255, 255, 0.5); +} + +.todo-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; + min-width: 132px; + justify-content: flex-end; +} + +/* Action Buttons */ +.action-btn { + width: 40px; + height: 40px; + border: none; + border-radius: 6px; + cursor: pointer; + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s ease; + -webkit-text-fill-color: currentColor; +} + +.action-btn-complete { + color: #10b981; + font-size: 20px; +} + +.action-btn-complete:hover { + background: rgba(16, 185, 129, 0.2); + color: #34d399; +} + +.action-btn-edit { + color: #3b82f6; +} + +.action-btn-edit:hover { + background: rgba(59, 130, 246, 0.2); + color: #60a5fa; +} + +.action-btn-delete { + color: #ef4444; +} + +.action-btn-delete:hover { + background: rgba(239, 68, 68, 0.2); + color: #f87171; +} + +/* Add Todo Button */ +.add-todo-btn { + width: 36px; + height: 36px; + border: none; + border-radius: 6px; + cursor: pointer; + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 18px; + font-weight: normal; + -webkit-text-fill-color: white; + transition: all 0.2s ease; +} + +.add-todo-btn:hover { + transform: scale(1.1); + box-shadow: 0 4px 20px rgba(59, 130, 246, 0.4); +} + +/* Todo Details */ +.todo-details { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.description { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; +} + +.description p { + margin: 0; + color: rgba(255, 255, 255, 0.8); + line-height: 1.6; +} + +.description.completed p { + text-decoration: line-through; + color: rgba(255, 255, 255, 0.5); +} + +.todo-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; +} + +.meta-dates { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.meta-item { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); +} + +.completion-badge { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + color: #10b981; + font-weight: 500; +} + +.completion-icon { + width: 0.875rem; + height: 0.875rem; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .nav-container { + padding: 1rem; + flex-direction: column; + gap: 1rem; + } + + .nav-links { + gap: 1rem; + flex-wrap: wrap; + justify-content: center; + } + + .container { + padding: 1rem; + } + + .form-actions { + flex-direction: column; + } + + .edit-actions { + flex-direction: column; + } + + .todo-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .todo-actions { + align-self: stretch; + justify-content: center; + } + + .meta-dates { + flex-direction: column; + gap: 0.25rem; + } + + .todo-meta { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } +} + +/* File Upload */ +.file-upload-btn { + min-height: 120px; + flex-direction: column; + gap: 0.5rem; + width: 100%; + border: 2px dashed rgba(255, 255, 255, 0.3); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.file-upload-btn:hover { + border-color: rgba(255, 255, 255, 0.5); + background-color: rgba(255, 255, 255, 0.05); +} + +.file-upload-info { + margin-top: 0.5rem; + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.8); +} + +/* File Table */ +.file-table { + width: 100%; + border-collapse: collapse; +} + +.file-table th { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + font-weight: 500; + font-size: 0.875rem; +} + +.file-table th:last-child { + text-align: center; +} + +.file-table td { + padding: 0.75rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.file-table tr:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +.file-name { + color: white; + font-weight: 500; +} + +.file-meta { + color: rgba(255, 255, 255, 0.6); + font-size: 0.875rem; +} + +.file-actions { + display: flex; + gap: 0.5rem; + justify-content: center; +} + +/* Responsive File Table */ +@media (max-width: 768px) { + .file-table { + font-size: 0.875rem; + } + + .file-table th, + .file-table td { + padding: 0.5rem; + } + + .file-actions { + flex-direction: column; + gap: 0.25rem; + } +} +``` + + + + + +### Run and test the Application + +Start the development server to test your route protection implementation: + +```bash +npm run dev +``` + +Things to try out: + +1. Try navigating to `/profile` - you should be redirected to the homepage `/` since you're not authenticated. +2. Because you are not signed in, the navigation bar should only show the "Home" link and the placeholder for signin/signup links. +3. On the homepage, you should see a message indicating that you are not signed in. + +After we complete the next tutorial on user authentication, you will be able to sign in and access the protected `/profile` page and see how the navigation bar and homepage updates accordingly. + + + + +## How It Works + +1. **useAuth Composable**: Manages authentication state using Nhost's client and provides reactive state through Vue's composition API +2. **Vue Router Guards**: Navigation guards check authentication status before allowing access to protected routes +3. **Profile Page**: A protected page that displays user information, only accessible when authenticated +4. **Automatic Redirects**: Unauthenticated users are redirected to `/`, authenticated users can access `/profile` + +## Key Features Demonstrated + + + +Routes are protected using Vue Router navigation guards and authentication composable, preventing unauthorized access to sensitive areas. + + + +Smooth loading indicators are shown during authentication checks to improve user experience. + + + +Users are automatically redirected based on their authentication status, ensuring proper navigation flow. + + + +Authentication state is synchronized across multiple browser tabs using Nhost's session storage events. + + + +Complete user session and profile information is displayed and managed throughout the application. + + diff --git a/docs/getting-started/tutorials/vue/3-user-authentication.mdx b/docs/getting-started/tutorials/vue/3-user-authentication.mdx new file mode 100644 index 000000000..89e8599ea --- /dev/null +++ b/docs/getting-started/tutorials/vue/3-user-authentication.mdx @@ -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. + + +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. + + +## Full-Stack Vue Development with Nhost + + + +Set up your Nhost project + + + +Route protection basics + + + +**Current** - Complete auth flow + + + +CRUD operations with GraphQL + + + +File upload and management + + + +## 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 + + + + +### 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 + + + +``` + + + + + +### 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 + + + +``` + + + + + +### 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 + + + +``` + + +**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. + + + + + + +### 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; +``` + + + + +### 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} + + + +``` + + + + + +### 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. + + + + +## Key Features Demonstrated + + + +Full email/password registration with proper form validation and user feedback. + + + +Custom `/verify` endpoint that securely processes email verification tokens. + + + +Comprehensive error handling for unverified emails, failed authentication, and network issues. + + + +Loading states, success messages, and clear error displays throughout the authentication flow. + + + +Complete sign out functionality and proper session state management across the application. + + diff --git a/docs/getting-started/tutorials/vue/4-graphql-operations.mdx b/docs/getting-started/tutorials/vue/4-graphql-operations.mdx new file mode 100644 index 000000000..e8ece7655 --- /dev/null +++ b/docs/getting-started/tutorials/vue/4-graphql-operations.mdx @@ -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. + + +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. + + +## Full-Stack Vue Development with Nhost + + + +Set up your Nhost project + + + +Route protection basics + + + +Complete auth flow + + + +**Current** - CRUD operations with GraphQL + + + +File upload and management + + + +## 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 + + + + +### 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: + + + + + +```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(); + +``` + + + + + ![Database SQL Editor](/images/tutorials/todos/1.png) + + + + + + +Please make sure to enable **Track this** so that the new table todos is available through the auto-generated APIs + + + + + + +### 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**. + + + + + +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) + + + + +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) + + + + +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) + + + + +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) + + + + + + + + + +### 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 +