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.

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.

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

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.

  • SQL Editor

  • UI

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
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 
); 

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.

  • insert

  • select

  • update

  • delete

Click on the right cell for the user role and action insert and set permissions as follows: User Insert

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.

  • insert

  • select

  • delete

Click on the right cell for the user role and action insert and set permissions as follows: User Insert

To enable Magic Links, navigate to your project’s Settings -> Sign-In Methods, toggle Magic Link, and save.

Recap

1

Nhost project created

2

Database todos created

3

Permissions set for todos and files

4

Magic Link enabled

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.

Terminal
npx create-next-app@latest --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.

Terminal
cd nhost-nextjs && npm install @nhost/nextjs

Configure the Nhost Client

Create a new file with the following code to create a Nhost client. Replace <SUBDOMAIN> and <REGION> with the values from the project created earlier.

./src/lib/nhost.ts
import { NhostClient } from "@nhost/nextjs";

export const nhost = new NhostClient({
  subdomain: "<SUBDOMAIN>",
  region: "<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:

./src/app/signin.js
"use client";

import { useState } from 'react'
import { useSignInEmailPasswordless } from '@nhost/nextjs'

export default function SignIn() {
  const [loading, setLoading] = useState(false)
  const [email, setEmail] = useState('')

  const { signInEmailPasswordless, error } = useSignInEmailPasswordless()

  const handleSignIn = async (event) => {
    event.preventDefault()

    setLoading(true)
    const { error } = await signInEmailPasswordless(email)

    if (error) {
      console.error({ error })
      return
    }

    setLoading(false)
    alert('Magic Link Sent!')
  }

  return (
    <div>
      <h1>Todo Manager</h1>
      <p>powered by Nhost and React</p>
      <form onSubmit={handleSignIn}>
        <div>
          <input
            type="email"
            placeholder="Your email"
            value={email}
            required={true}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <button disabled={loading}>
            {loading ? <span>Loading</span> : <span>Send me a Magic Link!</span>}
          </button>
        </div>
        {error && <p>{error.message}</p>}
      </form>
    </div>
  )
}

Setup Todos Component

Now that users can sign in, let’s move on and create the authenticated page that lists a user’s todos and has a form for managing todos with attachments.

./src/app/todos.jsx
"use client";

import { useState, useEffect } from 'react'
import { useNhostClient, useFileUpload } from '@nhost/nextjs'

const deleteTodo = `
    mutation($id: uuid!) {
      delete_todos_by_pk(id: $id) {
        id
      }
    }
  `
const createTodo = `
    mutation($title: String!, $file_id: uuid) {
      insert_todos_one(object: {title: $title, file_id: $file_id}) {
        id
      }
    }
  `
const getTodos = `
    query {
      todos {
        id
        title
        file_id
        completed
      }
    }
  `

export default function Todos() {
  const [loading, setLoading] = useState(true)
  const [todos, setTodos] = useState([])

  const [todoTitle, setTodoTitle] = useState('')
  const [todoAttachment, setTodoAttachment] = useState(null)
  const [fetchAll, setFetchAll] = useState(false)

  const nhostClient = useNhostClient()
  const { upload } = useFileUpload()

  useEffect(() => {
    async function fetchTodos() {
      setLoading(true)
      const { data, error } = await nhostClient.graphql.request(getTodos)

      if (error) {
        console.error({ error })
        return
      }

      setTodos(data.todos)
      setLoading(false)
    }

    fetchTodos()

    return () => {
      setFetchAll(false)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [fetchAll])

  const handleCreateTodo = async (e) => {
    e.preventDefault()

    let todo = { title: todoTitle }
    if (todoAttachment) {
      const { id, error } = await upload({
        file: todoAttachment,
        name: todoAttachment.name
      })

      if (error) {
        console.error({ error })
        return
      }

      todo.file_id = id
    }

    const { error } = await nhostClient.graphql.request(createTodo, todo)

    if (error) {
      console.error({ error })
    }

    setTodoTitle('')
    setTodoAttachment(null)
    setFetchAll(true)
  }

  const handleDeleteTodo = async (id) => {
    if (!window.confirm('Are you sure you want to delete this TODO?')) {
      return
    }

    const todo = todos.find((todo) => todo.id === id)
    if (todo.file_id) {
      await nhostClient.storage.delete({ fileId: todo.file_id })
    }

    const { error } = await nhostClient.graphql.request(deleteTodo, { id })
    if (error) {
      console.error({ error })
    }

    setFetchAll(true)
  }

  const completeTodo = async (id) => {
    const { error } = await nhostClient.graphql.request(
      `
      mutation($id: uuid!) {
        update_todos_by_pk(pk_columns: {id: $id}, _set: {completed: true}) {
          completed
        }
      }
    `,
      { id }
    )

    if (error) {
      console.error({ error })
    }

    setFetchAll(true)
  }

  const openAttachment = async (todo) => {
    const { presignedUrl, error } = await nhostClient.storage.getPresignedUrl({
      fileId: todo.file_id
    })

    if (error) {
      console.error({ error })
      return
    }

    window.open(presignedUrl.url, '_blank')
  }

  return (
    <>
      <div className="container">
        <div className="form-section">
          <h2>Add a new TODO</h2>
          <form onSubmit={handleCreateTodo}>
            <div className="input-group">
              <label htmlFor="title">Title</label>
              <input
                id="title"
                type="text"
                placeholder="Title"
                value={todoTitle}
                onChange={(e) => setTodoTitle(e.target.value)}
              />
            </div>
            <div className="input-group">
              <label htmlFor="file">File (optional)</label>
              <input id="file" type="file" onChange={(e) => setTodoAttachment(e.target.files[0])} />
            </div>
            <div className="submit-group">
              <button type="submit" disabled={!todoTitle}>
                Add Todo
              </button>
            </div>
          </form>
        </div>
        <div className="todos-section">
          {(!loading &&
            todos.map((todo) => (
              <div className="todo-item" key={todo.id ?? 0}>
                <input
                  type="checkbox"
                  checked={todo.completed}
                  disabled={todo.completed}
                  id={`todo-${todo.id}`}
                  onChange={() => completeTodo(todo.id)}
                />
                {todo.file_id && (
                  <span>
                    <a onClick={() => openAttachment(todo)}> Open Attachment</a>
                  </span>
                )}
                <label htmlFor={`todo-${todo.id}`} className="todo-title">
                  {todo.completed && <s>{todo.title}</s>}
                  {!todo.completed && todo.title}
                </label>
                <button type="button" onClick={() => handleDeleteTodo(todo.id)}>
                  Delete
                </button>
              </div>
            ))) || (
            <div className="todo-item">
              <label className="todo-title">Loading...</label>
            </div>
          )}
        </div>
      </div>

      <div className="sign-out-section">
        <button type="button" onClick={() => nhostClient.auth.signOut()}>
          Sign Out
        </button>
      </div>
    </>
  )
}

With both SignIn and Todos in place, update ./src/App.jsx to use the new components:

./src/app/App.js
"use client";

import './App.css'
import { NhostProvider } from '@nhost/nextjs'
import { nhost } from '../lib/nhost.js'
import SignIn from './signin'
import Todos from './todos'
import { useEffect, useState } from 'react'

function App() {
  const [session, setSession] = useState(null)

  useEffect(() => {
    setSession(nhost.auth.getSession())

    nhost.auth.onAuthStateChanged((_, session) => {
      setSession(session)
    })
  }, [])

  return (
    <NhostProvider nhost={nhost}>
      {session ? <Todos session={session} /> : <SignIn />}
    </NhostProvider>
  )
}

export default App

The End

Run the Todo Manager with:

Terminal
npm run dev -- --port 3000

Open your browser on localhost:3000 to see your new application in action.