Full Stack Nuxt Typescript App without tRPC

Aaron K Saunders - Sep 13 '23 - - Dev Community

Build a full-stack typescript nuxt application without the boilerplate code of tRPC using the nuxt-remote-fn module.

We will walkthough the steps of building a simple Nuxt application to save and retrieve data from a SQLite database through a type-safe server-side API.

This blog post is a companion to the video tutorial below.

Project Setup

Install the required packages.

pnpm add nuxt-remote-fn better-sqlite3
pnpm add -D @types/better-sqlite3
Enter fullscreen mode Exit fullscreen mode

Configure the module by updating nuxt.config.ts

// nuxt.config.ts

export default defineNuxtConfig({
  modules: [
    'nuxt-remote-fn',
  ],
})
Enter fullscreen mode Exit fullscreen mode

Simple Implementation

Create a new file api.server.ts file in a new directory named lib directory.

Add a new function; this function will simply print out a string using the value passed in as the parameter

export const basicFunction = (value: string) => {
  return `Basic function ${value}`;
};
Enter fullscreen mode Exit fullscreen mode

You can see the function in action by updating the code in the app.vue file to the following.

Important thing to note is that you are simply importing the api.server.ts module but you get the function and the types associated with the function. You are making a server-side call without a lot of boilerplate, or traditional http request code.

<template>
    <p>{{basicResult}}</p>
</template>
<script setup lang="ts">
import { basicFunction } from "./lib/api.server";

const basicResult = await basicFunction("Aaron");

</script>
Enter fullscreen mode Exit fullscreen mode

That's basically the hello world of nuxt-remote-fn module.

Going Deeper

So for the next part we are going to create an api that allows us to read objects from sqlite database and write objects to the database.

The first step is creating a small object to set the database up and provide access to it.

create a file /lib/sqlite-service

import Database from "better-sqlite3";

const db = new Database("stuff.db");
db.pragma("journal_mode = WAL");

const createTable = db.prepare(`
    CREATE TABLE IF NOT EXISTS stuff (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      stuff TEXT NOT NULL,
      important BOOL
    );
  `);

createTable.run();

export { db };
Enter fullscreen mode Exit fullscreen mode

This sets up the database and creates the default table if it doesn't exist yet.

Now let's import the sqlite-service module into the /lib/api.server.ts file, and add a few types that we will be using

import { db } from "./sqlite-service";

type Stuff = { id: number; stuff: string; important: 0 | 1 };
type StuffInput = { stuff: string; important: 0 | 1 };
Enter fullscreen mode Exit fullscreen mode

Add Stuff To Database

So let's add the function to add stuff to the database.
Notice how we are specifying the return type, so it is available in the client application and utilizing db from the sqlite-service.

We are also specifying the types for the function parameters using StuffInput type. This will be checked on client side and visible with intellisense.

export const addTheStuff = async ({stuff, important}: StuffInput) => {

  // prepare the query
  const prep = db.prepare("INSERT INTO stuff (stuff, important)VALUES (?,? );");

  // execute the insert
  const result = prep.run(stuff, important);

  return result;
};
Enter fullscreen mode Exit fullscreen mode

Next lets update the app.vue to add template changes for entering information to be saved to database and a simple function to insert items into the database.

Template Changes

  • Updated the template to include an input field for getting the text description of the stuff, the field in bound to the ref stuffInput. We did the same for the important flag in the database using the ref importantInput
  • we added a button for saving the data and added a function addStuff as the click event handler.
<template>
  <div>
    <div style="margin: 16px">
      <div style="margin: 8px">
        <input type="text" placeholder="Describe Stuff" v-model="stuffInput" />
      </div>
      <div style="margin: 8px">
        <label for="important">
          <span>Important</span>
          <input
            v-model="importantInput"
            name="important"
            type="checkbox"
            placeholder="Important"
          />
        </label>
      </div>
      <button style="margin: 8px" @click="addStuff">ADD</button>
    </div>

  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Script Changes

  • imported addTheStuff function from /lib/api.server
  • added refs for stuffInput, importantInput
  • added ref for newRecordResult which will hold the result from the function addStuff
  • added function addStuff, the function is using the imported method addTheStuff from /lib/api.server
  • in the function we using the refs stuffInput, importantInput as parameters for saving the data. We do need to convert the boolean to an integer to be saved to the database and finally we clear the input refs and we set the ref newRecordResult to the response from the server.
<script setup lang="ts">
import { addTheStuff } from "./lib/api.server";
const stuffInput = ref("");
const importantInput = ref<boolean>(false);
const newRecordResult = ref();

/**
 *
 */
const addStuff = async () => {
    newRecordResult.value = await addTheStuff({
      stuff: stuffInput.value,
      important: importantInput.value ? 1 : 0,
    });

    // clear input
    stuffInput.value = '';
    importantInput.value = false;
};
</script>
Enter fullscreen mode Exit fullscreen mode

Get All The Stuff Data

Now let's add the getAllTheStuff function to get all of the items from the database; notice how we are specifying the return type, so it is available in the client application and utilizing db from the sqlite-service.

export const getAllTheStuff = async () => {
  // prepare the query
  const prep = db.prepare("SELECT * FROM stuff ");

  // execute the query
  const rows = prep.all() as Stuff[];

  return rows;
};
Enter fullscreen mode Exit fullscreen mode

Next lets update the app.vue to add template changes for rendering the items in the database.

Template Changes
We wont need to be fancy, we will just loop through the values in the results from the server API query.

You can add this code to the bottom of the template.

<p v-if="error">Error: {{ error }}</p>
<div v-for="item in stuff" :key="item.id">
   <p>
    {{ item.important === 1 ? "[IMPORTANT] - " : '' }}
    {{item.stuff}}  
   </p>
</div>
Enter fullscreen mode Exit fullscreen mode

Script Changes
The source code changes are pretty simple.

  • We need to import getAllTheStuff from /lib/api.server.ts
  • We are using useAsyncData to query the database so we can just pass it our server function.
  • We added the watch option to useAsyncData so that whenever our function addStuff has a valid response we set newRecordResult and it will trigger the useAsyncData function to refetch the data
import { getAllTheStuff, addTheStuff } from "./lib/api.server";

const { data: stuff, error } = useAsyncData("stuff", () => getAllTheStuff(), {
  watch: [newRecordResult],
});
Enter fullscreen mode Exit fullscreen mode

Handling Errors

We can throw NuxtError from the remote server function that can then be handled by the client application.

In the code below we updated the addStuff remote server function to verify that there is a value for stuff and if now throw an error using createError, we also throw an error if the database cannot add the record.

export const addTheStuff = async ({stuff, important}: StuffInput) => {

  if (!stuff  || stuff.length === 0) {
    throw createError('Invalid Parameter')
  }

  // prepare the query
  const prep = db.prepare("INSERT INTO stuff (stuff, important)VALUES (?,? );");

  // execute the insert
  const result = prep.run(stuff, important);

  if (result.changes === 0) {
    throw createError('Record Not Added')
  }

  return result;
};
Enter fullscreen mode Exit fullscreen mode

Now that the errors are thrown, we can check on the client for the errors and display them in the UI.

Template Changes

<p v-if="newRecordError">Error Adding Record: {{ newRecordError }}</p>
Enter fullscreen mode Exit fullscreen mode

Script Changes

  • wrap the code in function with try/catch block and set the ref newRecordError with the error message to be displayed in the template.
const addStuff = async () => {
  try {
    newRecordResult.value = await addTheStuff({
      stuff: stuffInput.value,
      important: importantInput.value ? 1 : 0,
    });

    // clear input
    stuffInput.value = '';
    importantInput.value = false;

  } catch (error) {
    newRecordError.value = (error as NuxtError).data.message;
  }
};
Enter fullscreen mode Exit fullscreen mode

Links

Social Media

Twitter - https://twitter.com/aaronksaunders
Facebook - https://www.facebook.com/ClearlyInnovativeInc
Instagram - https://www.instagram.com/aaronksaunders/
Dev.to - https://dev.to/aaronksaunders

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .