Full-Stack Development with Ionic and Nitric: Building the Notes Application Part 1

New
15 min read
Ryan Edge
Ryan Edge
Published January 6, 2025

In the previous article, we extended our application with authentication and integrated it with the API.

Now, will start building a note-taking application. We will focus on adding full-stack functionality for creating and viewing notes.

Adding Dependencies

Before building, let's add a few dependencies on the client side.

Run the following command in a terminal from the application (notes-app) folder.

shell
1
npm i ky clsx @vanilla-extract/dynamic @vanilla-extract/css @tanstack/react-form

The previous command installs the following dependencies.

  • ky: a tiny HTTP client based on the Fetch API
  • clsx: a utility for constructing className strings conditionally.
  • @vanilla-extract/css: Zero-runtime stylesheets in TypeScript
  • @vanilla-extract/dynamic: Runtime for performing dynamic updates to scoped theme variables
  • @tanstack/react-form: Hooks for managing form state in React

We will use these dependencies to help us communicate with the backend API and build UI components.

Additionally, we need to set up the styling plugins with the Vite build system. To do that run the following command in the same location:

shell
1
npm i -D @vanilla-extract/vite-plugin

Then, inside of vite.config.ts replace the plugins array property to resemble the following:

javascript
1
plugins: [vanillaExtractPlugin(), react(), legacy()],

This plugin will allow us to use style and other APIs in .css.ts files.

Now that we've set up the frontend dependencies, let's start building APIs.

Setting Up Notes API

To start let's create middleware to handle the authorization of our users. Inside the API project, create a file called authorize.ts in the shared/middleware folder. Add the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { HttpContext, HttpMiddleware } from "@nitric/sdk"; import getAuthClient from "../../resources/auth"; import { AuthenticationInfo } from "@descope/node-sdk"; export type HttpContextWithAuthentication = HttpContext & { authentication: AuthenticationInfo; }; /** * Middleware function to authorize users. * * This function verifies that the incoming request contains a JWT token in the * authorization header and checks if the user is a member of the "authors" Cognito group. */ const authorize: HttpMiddleware = async ( ctx: HttpContextWithAuthentication, next ) => { const authHeader = Array.isArray(ctx.req.headers["authorization"]) ? ctx.req.headers["authorization"][0] : ctx.req.headers["authorization"]; const token = authHeader?.split(" ")[1]; if (!token) { ctx.res.status = 401; ctx.res.body = "Unauthorized"; return ctx; } const authClient = getAuthClient(); const authInfo = await authClient.validateSession(token); // It's valuable to validate the token's shape, skipped here for brevity. ctx.authentication = authInfo; return next?.(ctx); }; export default authorize;

This code creates a security checkpoint for a web application, similar to a bouncer checking IDs at a club entrance. The main purpose is to make sure only authorized users can access certain parts of the application by verifying they have a valid authentication token.

The code takes in two main inputs: a context object (ctx) that contains information about the incoming web request, and a "next" function that allows the request to continue if authorization is successful. The most important input it looks for is an "authorization" header in the request, which should contain a security token.

Next, let's make a shared API resource for our Notes API. Inside the API project, create a apis.ts in the resources folder if one does not already exist. Add the following code:

javascript
1
2
3
4
import { api } from "@nitric/sdk"; import authorize from "../shared/middleware/authorize"; export const notesApi = api("notes", { middleware: [authorize] });

This code creates a secure API endpoint for handling notes in an application. Let's break down what it does:

The code sets up a new API called "notes" that will handle all note-related operations (like creating, reading, updating, and deleting notes). The code additionally configures authorization by using the authorize middleware. This ensures that every request going through this API must first pass through the authorization check.

If missing, add another file to the same folder called stores.ts and add the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { kv } from "@nitric/sdk"; export const noteStore = kv<{ notes: Note[] }>("notes"); export function getNoteStore( perm: StorePermission, ...perms: StorePermission[] ) { return noteStore.allow(perm, ...perms); } export type Note = { id: string; title: string; content: string; }; type StorePermission = "get" | "set" | "delete";

The code defines a storage structure using a key-value (kv) database from the Nitric SDK. The storage is designed to hold an array of Note objects, where each Note has three pieces of information: an id, a title, and content. This is similar to how a physical note would have a unique identifier, a title at the top, and the note content below.

The code provides two main pieces of functionality:

  • It creates a storage container called noteStore that's configured to hold notes
  • It provides a helper function getNoteStore that controls what operations (permissions) are allowed on the storage - like being able to get notes, set/save notes, or delete notes

Now we can put it all together by creating our API to create, read, update, and delete notes.

Getting Notes

Adding the API Request

To allow our users to read notes that they have created, we will handle a GET request from the application to the /notes endpoint. Make a file in the services folder called notes.ts and add the following dependencies for the notes store and the authorization middleware to the top of the file:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { HttpContextWithAuthentication } from "../shared/middleware/authorize"; import { getNoteStore, Note } from "../resources/stores"; import { notesApi } from "../resources/apis"; Then we will define our API endpoint. After the last dependency, add the following code. notesApi.get("/notes", async (ctx: HttpContextWithAuthentication) => { const { sub: userId } = ctx.authentication.token; if (userId == null) { ctx.res.status = 400; return ctx; } try { const store = getNoteStore("get"); ctx.res.json((await store.get(userId)).notes); } catch (error) { ctx.res.json([]); } return ctx; });

This code defines an API endpoint that retrieves all notes belonging to a specific user.

The code sets up a GET request handler at the /notes URL path. When someone makes a request to this endpoint, they must be authenticated (logged in), and the code expects to receive a user ID (userId) from their authentication token. The user ID is used to retrieve the notes for that user.

If no valid user is found, a 400 status code is returned in the response. If the retrieval is successful, a JSON array containing all notes for that user is returned. Finally, if there's an error, an empty array is returned.

Run the API locally by using nitric start from the root of the API folder. Upon testing the new endpoint in the dashboard, we should see an error resembling the following screenshot:

Because the endpoint is protected and the authorization request is empty, we are seeing the 401 Unauthenticated error. Let's handle this scenario in the application by adding the logic to display notes.

Getting Notes in the Application

Inside of the notes project create a file called use-http-client.ts in the shared/api folder and add the following code.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useSession } from "@descope/react-sdk"; import ky from "ky"; export default function useHttpClient() { const { sessionToken } = useSession(); const api = ky.extend({ hooks: { beforeRequest: [ (request) => { request.headers.set("Authorization", `Bearer ${sessionToken}`); }, ], }, }); return api; }

This code creates a shared resource for making API requests. The useHttpClient hook will create an API client using the ky dependency and inject the authorization header with the session token from Descope's useSession hook, the library that we are using to authorize users.

We will put this hook to use by creating a hook to retrieve notes. Inside of entities/note/api create a file called notes.ts and add the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useQuery, useQueryClient } from "@tanstack/react-query"; import useHttpClient from "../../../shared/api/use-http-client"; import { useSession } from "@descope/react-sdk"; export type Note = { id: string; title: string; content: string; }; export default function useQueryNotes() { const httpClient = useHttpClient(); const { isSessionLoading } = useSession(); return useQuery<Note[]>({ queryKey: [`notes`], queryFn: async () => { const response = await httpClient.get(`/api/notes`).json(); return (await response) as Note[]; }, enabled: !isSessionLoading, }); }

This code creates a custom React hook called useQueryNotes that handles fetching notes data from an API.

The hook works by combining several tools:

  • It uses a HTTP client hook to make web requests
  • It checks if there's an active user session. If the session is inactive (isSessionLoading) the request is disabled.
  • It uses React Query to manage the data fetching and caching with the unique notes query key.

Now let's create the UI. Inside of home-page.tsx replace the HomePage component with the following code (make sure to resolve all the dependencies):

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export default function HomePage() { const logout = useLogout(); const { data: notes, isPending, isError, error, isSuccess } = useQueryNotes(); return ( <IonPage> <IonHeader> <IonToolbar> <IonTitle>Notes</IonTitle> <IonButtons slot="end"> <IonButton size="small" routerLink="/notes/create"> Create note </IonButton> <IonButton size="small" onClick={logout}> Sign Out </IonButton> </IonButtons> </IonToolbar> </IonHeader> <IonContent className="ion-padding"> {isPending && <HomePagePending />} {isError && <HomePageError error={error} />} {isSuccess && <HomePageSuccess notes={notes!} />} </IonContent> </IonPage> ); }

This component serves as the home page where users can view their notes, create new notes, and sign out. It's like the main dashboard of the application.

The component doesn't take any direct props or parameters, but it uses two custom hooks:

  • useLogout: Handles the sign-out functionality
  • useQueryNotes: Fetches the list of notes from the API

It produces a page layout that shows:

  • A header with the title "Notes"
  • Buttons for creating notes and signing out
  • The main content area that displays either:
    • A loading skeleton while notes are being fetched
    • An error message if something goes wrong
    • The list of notes when successfully loaded

The components for the main content have not been created, so let's do that now.

First, add the component HomePageError below HomePage :

javascript
1
2
3
4
5
6
7
8
function HomePageError({ error }: { error: Error }) { return ( <> <IonIcon icon={alert} size="large"></IonIcon> <IonText>{error.message}</IonText> </> ); }
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

This widget will display an error message to the user.

Next, add the HomePagePending component:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function HomePagePending() { return ( <IonList> <IonListHeader> <IonSkeletonText animated={true} style={{ width: "80px" }} ></IonSkeletonText> </IonListHeader> <IonItem> <IonLabel> <h3> <IonSkeletonText animated={true} style={{ width: "80%" }} ></IonSkeletonText> </h3> <p> <IonSkeletonText animated={true} style={{ width: "60%" }} ></IonSkeletonText> </p> <p> <IonSkeletonText animated={true} style={{ width: "30%" }} ></IonSkeletonText> </p> </IonLabel> </IonItem> </IonList> ); }

This component will display a loading skeleton animation mirroring the notes tiles.

Finally, add the HomePageSuccess component with the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function HomePageSuccess({ notes }: { notes: Note[] }) { if (notes.length === 0) { return ( <div> <IonLabel> <h1>No notes created</h1> </IonLabel> <IonButton size="large" routerLink="/notes/create"> Create note </IonButton> </div> ); } return ( <> <div> <NoteListView notes={notes}></NoteListView> </div> <IonFab slot="fixed" vertical="bottom" horizontal="end"> <IonFabButton> <IonIcon icon={chevronUpCircle}></IonIcon> </IonFabButton> </IonFab> </> ); }

The main purpose of this component is to show either a "no notes" message with a create button when there are no notes, or display a list of existing notes with a floating action button when notes exist.

If there are notes present, it renders a different view with two main parts:

  1. A NoteListView component that receives the notes array to display the notes
  2. A floating action button (IonFab) fixed to the bottom-right corner of the screen, showing an upward-pointing chevron icon

We have not yet defined the NoteListView component, so let's do that. Inside of the entities/note/ui folder, create a file called note-list-view.tsx and add the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
import { Note } from "../../api/notes"; import NoteListCard from "../note-list-card/note-list-card"; export default function NoteListView({ notes }: { notes: Note[] }) { return ( <> {notes.map((note) => ( <NoteListCard key={note.id} note={note} /> ))} </> ); }

Add another file called note-list-card and add the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { IonAlert, IonButton, IonCard, IonCardContent, IonCardHeader, IonCardTitle, } from "@ionic/react"; import { Note } from "../../api/notes"; export default function NoteListCard({ note }: { note: Note }) { return ( <IonCard className="ion-padding"> <IonCardHeader> <IonCardTitle>{note.title}</IonCardTitle> </IonCardHeader> <IonCardContent>{note.content}</IonCardContent> <IonButton fill="clear" routerLink={`/notes/${note.id}/d`}> Edit Note </IonButton> </IonCard> ); }

These two components work together to display notes. The NoteListView component takes in an array of notes and maps those to NoteListCard. NoteListCard will display a card for each note with the following:

  • The note title and content
  • A mechanism to delete notes for which no endpoint currently exists.
  • A mechanism to navigate to a note for editing.

Run the application using the npm run dev command in the application folder. Upon a successful login, you will see an empty notes page with the option to create a note. The result should resemble the following screenshot.

Notice that if you click on the button to create a note, you will be taken to a blank page. This is because the screen and associated route don't exist yet. Now, let's move on to creating our notes to round out this example.

Creating Notes

Adding the API Request

Before creating the API, let's set up a dependency to validate any user's request to create a note. Create a file in the shared/schemas folder called note.ts and add the following code:

javascript
1
2
3
4
5
6
import { z } from "zod"; export const createNoteSchema = z.object({ title: z.string().min(1), content: z.string().min(1), });

This code creates a schema that we will use to validate the user's request to create a note.

Next, in the notes service, add the following code to the bottom of the file.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
notesApi.post("/notes", async (ctx: HttpContextWithAuthentication) => { const input = createNoteSchema.safeParse(ctx.req.json()); const { sub: userId } = ctx.authentication.token; console.log("here"); const store = getNoteStore("get", "set"); if (input.success == false) { ctx.res.status = 400; ctx.res.json(input.error.issues); return ctx; } if (userId == null) { ctx.res.status = 400; return ctx; } const newNote = { id: randomUUID(), ...input.data }; let notes: { notes: Note[] } = { notes: [] }; try { notes = await store.get(userId); } catch (error) { console.log("no notes created for this user yet"); } await store.set(userId, { ...notes, notes: [...notes.notes, newNote] }); ctx.res.json(newNote); return ctx; });

This code defines an API endpoint that allows authenticated users to create notes.

The code sets up an HTTP POST request handler at the /notes URL path, allowing users to create and save new notes to their account.

The handler takes two main inputs:

  1. The note content sent in the request body, which is parsed as JSON and validated against the createNoteSchema
  2. A user ID that is parsed from the authentication token, ensuring logged-in users can create notes.

The output is either the newly created note (on success) or error messages (on failure). When successful, it returns the created note object that includes a randomly generated ID and the user's input data.

Now let's explore how to make these requests on the frontend.

Creating Notes in the Application

To start, extend the entity/notes/api/notes.ts file with the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export type CreateNoteParams = { title: string; content: string; }; export function useMutationCreateNote(options?: { onSuccess?: () => void; onError?: (error: Error) => void; }) { const httpClient = useHttpClient(); const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: async (note: CreateNoteParams) => { const response = await httpClient.post("/api/notes", { json: note }); return await response.json(); }, onSuccess: () => { options?.onSuccess?.(); queryClient.invalidateQueries({ queryKey: ["notes"] }); }, onError: options?.onError, }); return mutation; }

This function provides a way to create new notes via the endpoint that we created in the previous section. It includes logic to automatically updates the notes after a successful creation, using queryClient.invalidateQueries.

The function accepts an optional options object that can contain two callback functions:

  • onSuccess: A function to run when the note is successfully created
  • onError: A function to handle any errors that occur during note creation

The function returns a mutation object that contains methods and state for creating notes, including functions to trigger the creation process and track its status.

Next, create a file in the shared/ui/stack folder called stack.css.ts. Add the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { style, createVar, globalStyle, fallbackVar, } from "@vanilla-extract/css"; export const gap = createVar(); export const stack = style({ display: "flex", flexDirection: "column", }); globalStyle(`${stack} > * + *`, { marginTop: fallbackVar(gap, "1rem"), }); export const vstack = style([ stack, { display: "flex", flexDirection: "column", gap: fallbackVar(gap, "1rem"), }, ]); export const hstack = style([ stack, { display: "flex", flexDirection: "row", gap: fallbackVar(gap, "1rem"), }, ]);

This code creates reusable CSS styling components for laying out elements either vertically or horizontally with consistent spacing between them. It's like creating building blocks for arranging things on a webpage.

The code starts by creating a variable called gap that can be customized to control the spacing between elements. This gap defaults to "1rem" (a unit of measurement in CSS).

Here's how each component works:

  • The basic stack creates a flexible container that arranges its children elements in a column (vertically).
  • The vstack extends the basic stack and adds consistent vertical spacing between elements using the gap variable.
  • The hstack is similar but arranges elements horizontally (in a row) instead of vertically.

Next, create a page in pages/note called note-create-page.tsx and add the following code (make sure to resolve all the dependencies):

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
export default function NoteCreatePage() { const createNote = useMutationCreateNote(); const router = useIonRouter(); const form = useForm({ defaultValues: { title: "", content: "", }, onSubmit: async ({ value }) => { await createNote.mutateAsync(value); router.goBack(); }, }); return ( <IonPage> <IonHeader> <IonToolbar> <IonButtons slot="start"> <IonBackButton /> </IonButtons> <IonTitle>Create note</IonTitle> </IonToolbar> </IonHeader> <IonContent> <form onSubmit={(e) => { e.preventDefault(); e.stopPropagation(); form.handleSubmit(); }} className={vstack} style={assignInlineVars({ [gap]: "8px", })} > <form.Field name="title" validators={{ onChange: ({ value }) => { return value.length === 0 ? "Required" : undefined; }, }} children={(field) => { return ( <IonItem lines="none"> <IonInput id={field.name} name={field.name} className={clsx({ "ion-invalid": field.state.meta?.errors.length > 0, "ion-touched": field.state.meta.isTouched, })} type="text" label="Title" labelPlacement="stacked" placeholder="Add your note title" errorText={field.state.meta?.errors?.join(", ") ?? ""} value={field.state.value} onIonInput={(event) => field.handleChange(event.target.value?.toString() ?? "") } ></IonInput> </IonItem> ); }} ></form.Field> <form.Field name="content" validators={{ onChange: ({ value }) => { return value.length === 0 ? "Required" : undefined; }, }} children={(field) => { return ( <IonItem lines="none"> <IonTextarea id={field.name} name={field.name} className={clsx({ "ion-invalid": field.state.meta?.errors.length > 0, "ion-touched": field.state.meta.isTouched, })} rows={15} label="Content" labelPlacement="stacked" autoGrow placeholder="Add your note content" errorText={field.state.meta?.errors?.join(", ") ?? ""} value={field.state.value} onIonInput={(event) => field.handleChange(event.target.value ?? "") } ></IonTextarea> </IonItem> ); }} ></form.Field> <IonRow> <IonCol> <IonButton expand="block" type="submit" disabled={createNote.isPending} > Done </IonButton> </IonCol> </IonRow> </form> </IonContent> </IonPage> ); }

The purpose of this code is to display a form with two main input fields: a title and content area for the note. When users fill out these fields and click the "Done" button, it saves their note and takes them back to the previous screen.

For inputs, the page accepts user-entered text in two fields:

  1. A title field for naming the note
  2. A larger content field for the note text

Using the useForm hook from @tanstack/react-form, the component will validate the fields on submission and execute createNote.mutateAsync() which comes from the previously created mutation. If the mutation succeeds, the page will navigate back to the previous screen.

To wire up this screen, open router.tsx and add the following route code after the /dashboard route (make sure to resolve all the dependencies):

\\</Route>

This code creates a route that can be accessed from notes/create in the browser.

Finally, we can navigate to the newly created page using the "Create note" buttons that we've previously ignored. Upon running the newly updated application, click on the "Create note" button on the dashboard page and you should see the new screen:

Upon submitting the form, you should see error messages if no values are provided:

Finally, add a note title and note content and submit the note. You should see the newly created note once you are redirected to the dashboard.

Conclusion

In this article, we extended our API with endpoints to create and retrieve notes and integrated the new endpoints in the application. In part two of this notes application build, we will extend our application with full-stack functionality for editing and deleting notes.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->