Building a full stack app with Remix, Prisma, and Neon

Joel Olawanle - Mar 12 - - Dev Community

In modern web development, scalable applications are paramount due to the ever-increasing demand for flexible, responsive systems that can handle growing user data and evolving functionalities. One way to achieve this involves leveraging technologies like Remix, Prisma, and Neon.

Remix enhances web application development with a user-centric and developer-friendly approach. Prisma offers streamlined data management for scalable growth, whereas Neon's serverless PostgreSQL ensures effortless scaling and reduced database complexity. By combining these tools, developers can build a robust ecosystem that empowers the crafting of such applications.

React and JavaScript frameworks have always reigned supreme for developing fast-loading apps. However, they often require separate server code for database or API interaction. Server-side rendering frameworks like Remix streamline this process by allowing you to combine client and server-side functionalities within a project.

This guide walks you through integrating Remix with a Neon serverless Postgres database. It focuses on how to set up the database and fetch data for a posts application using Prisma as the object-relational mapping (ORM) tool for efficient database management.

A demo of a fullstack application built with Remix, Prisma, and Neon

Prerequisite

To follow along with this guide, you should have:

Setting up the environment

To get started with this guide, having a Remix project is essential. If you already have a Remix project, skip to the “Setting up Prisma in Remix” section. If not, clone the starter project prepared for this guide. The complete project is at the end of this tutorial.

The starter project contains basic Remix code, including pages and layout files with styles. To clone the project to your local machine, follow these steps:

1) Navigate to your preferred directory for storing your code and execute the following command in your terminal:



git clone -b starter-files --single-branch https://github.com/olawanlejoel/PostHive.git


Enter fullscreen mode Exit fullscreen mode

2) Move into the project folder and install all the required dependencies:



cd PostHive
npm install


Enter fullscreen mode Exit fullscreen mode

3) Start the development server:



npm run dev


Enter fullscreen mode Exit fullscreen mode

The local development server starts on http://localhost:3000/ where you can access your Remix app.

Understanding the Remix file structure

You will notice some standard files for every Remix project, but focus on the app folder, which is the heart of every Remix application.

In the app folder, you will get the following structure:



/
|-- /app
    |-- /routes
        |-- _index.tsx
        |-- posts._index.tsx
        |-- posts.$postid.tsx
        |-- posts.new.tsx
        |-- posts.tsx
    |-- /styles
        |-- global.css
    |-- entry.client.tsx
    |-- entry.server.tsx
    |-- root.tsx


Enter fullscreen mode Exit fullscreen mode
  • entry.client.tsx: Serves as the entry point for client-side code, mainly for browser rendering. We'll stick to the basic code here and won't delve deeper.
  • entry.server.tsx: The entry point for server-side rendering, responsible for initial server rendering. We'll focus on basic code here as well.
  • root.tsx: This file represents the root component of your application, shaping the primary UI structure. Here, we've included the navbar code, intending to be global across all pages, and imported the CSS file.
  • /styles: This directory holds your application's stylesheets or CSS files.
  • /routes: Manages various routes or pages within your app. Any file created in this folder serves as a route.
    • _index.tsx: Represents the index or home page, displaying some text and a button.
    • posts._index.tsx: Displays an array of all posts. It should retrieve these posts from the Neon database and showcase them within the Remix application.
    • posts.$postid.tsx: Manages individual posts, where $postid acts as a placeholder for the post ID. Configuration for this occurs when integrating the server-side actions.
    • posts.new.tsx: Displays a form for creating new posts to be stored in the database.
    • posts.tsx: This serves as the layout for the posts pages.

Loaders and Actions in Remix

Understanding Loaders and Actions in Remix is pivotal for effective interaction with databases like Prisma and Neon. These concepts are crucial in managing data retrieval and executing necessary operations within the database.

Loaders: Fetching data with useLoaderData

Within the posts._index.tsx file, a loader function is created to declare the posts array and access its value using the useLoaderData function from Remix.

useLoaderData is a function in Remix that facilitates data fetching from various sources, including databases such as Neon via Prisma. By leveraging this function, developers can efficiently load necessary data for rendering components or executing actions in their applications.

Here's a snippet demonstrating its usage:



import { useLoaderData } from '@remix-run/react';

export const loader = async () => {
    const data = {
        posts: [
            // all posts
        ],
    };
    return data;
};


Enter fullscreen mode Exit fullscreen mode

This server-side code enables data access on the client side using the useLoaderData function:



const { posts } = useLoaderData();


Enter fullscreen mode Exit fullscreen mode

The posts data can then be looped through in your component for rendering:



<ul className="posts-list">
    {posts.map((post) => (
        <li key={post.id}>
            <Link to={post.id}>
                <h3>{post.title}</h3>
                {new Date(post.createdAt).toLocaleString()}
            </Link>
        </li>
    ))}
</ul>


Enter fullscreen mode Exit fullscreen mode

Note: This is a TypeScript file, so you’ll notice type checkings in the starter-files code.

Action: Handling form data submission

In the posts.new.tsx file, a form is present. To handle data submission from this form, Remix employs action. Similar to the loader, action facilitates operations or modifications within the database.

To use action, you’d create a function and export it:



export const action = () => {
    // server actions
    return {};
};


Enter fullscreen mode Exit fullscreen mode

You can get the data from a form by attaching a POST method and a type of submit to the button. You will also give each field a name, which can then be accessed in the action function using the request.*formData*() method:



export const action = async ({ request }) => {
    const form = await request.formData();
    const title = form.get('title');
    const body = form.get('body');

    const fields = { title, body};

    console.log(fields);
    return {};
};


Enter fullscreen mode Exit fullscreen mode

When you fill out the form and submit it, you will notice the values logged to the console of your terminal (not on the client side because it’s server-side code). You can import the redirect function to return redirect so once the form is submitted, it redirects you to another page:



import { redirect } from '@remix-run/node';

export const action = async ({ request }) => {
    const form = await request.formData();
    const title = form.get('title');
    const body = form.get('body');

    const fields = { title, body};

    console.log(fields);

    return redirect('/posts');
};


Enter fullscreen mode Exit fullscreen mode

In the above, we have not performed any significant action, but when dealing with databases, action will involve creating new database entries, updating existing records, deleting data, or executing complex queries to modify the database.

Note: Type-checking is not implemented in the snippet above, but remember to implement it since this is a TypeScript file.

Now that you understand how loaders and actions work, it's time to explore using Prisma and Neon with Remix.

Setting up Prisma in Remix

Prisma is a modern database toolkit that simplifies database access for developers, offering an intuitive interface to work with databases while providing a type-safe and powerful query API.

It supports databases like PostgreSQL, MySQL, SQLite, and SQL Server, allowing seamless interaction through its ORM (Object-Relational Mapping) capabilities. To set up Prisma, follow these steps:

1) Open your terminal and install Prisma alongside Prisma Client:



npm i prisma @prisma/client


Enter fullscreen mode Exit fullscreen mode

2) Initialize a new Prisma project in your Remix project, creating a prisma directory with a schema.prisma file:



npx prisma init


Enter fullscreen mode Exit fullscreen mode

After initializing, the schema.prisma file will contain a default configuration:



generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "postgresql"
    url      = env("DATABASE_URL")
}


Enter fullscreen mode Exit fullscreen mode

3) Next, define a basic Post model in Prisma that mirrors your database's structure. Add the following code to your schema.prisma file:



model Post {
    id        String    @id @default(uuid())
    title     String
    body      String
    createdAt DateTime  @default(now())
    updatedAt DateTime  @updatedAt
}


Enter fullscreen mode Exit fullscreen mode

The Post model defines essential attributes: a unique ID generated by uuid(), fields for title and body containing post content, and timestamps createdAt and updatedAt for creation and modification times.

To enable this Prisma configuration, create a .env file in your Remix project's root directory if one doesn't exist. This file should contain your Postgres database URL. You have not created the Postgres database; you'd do that with Neon.



DATABASE_URL='postgresql://USER:PASSWORD@HOST:PORT/DATABASE'


Enter fullscreen mode Exit fullscreen mode

Setting up a Postgres database with Neon

Neon is a fully managed, serverless PostgreSQL database. It separates storage and computing, offering unlimited autoscaling, branching, and storage.

Its serverless architecture allows instantaneous database creation, connection, and dynamic scaling computing based on usage to optimize costs and integrates with cloud object stores like S3 for cost-effective storage.

To set up a Postgres database, follow these steps:

  1. Visit the Neon website and create an account if you haven’t already.
  2. Start a new project (e.g., postapp).
  3. Keep the version as 15, the latest release.
  4. Name your database (e.g., postappdb).
  5. Choose your preferred Region and click Create project.

A modal will pop up, displaying the Postgres database URL. Copy this URL and save it in your .env file. Ensure you only copy the URL and remove psql.

With this, you have successfully created a Postgres database.

Seeding your Neon database with Prisma

Your Prisma project is currently configured, and your Postgres database is set up with Neon. Next, seed the database with primary data to test everything before diving into Remix interactions.

Start by creating a posts.ts file to contain an array of posts you want to seed into the database. Export the array like so:



export const posts = [
    {
        title: 'JavaScript Performance Tips',
        body: `We will look at 10 simple tips and tricks to increase the speed of your code when writing JS`,
    },
    {
        title: 'Tailwind vs. Bootstrap',
        body: `Both Tailwind and Bootstrap are very popular CSS frameworks. In this article, we will compare them`,
    },
    {
        title: 'Writing Great Unit Tests',
        body: `We will look at 10 simple tips and tricks on writing unit tests in JavaScript`,
    },
    {
        title: 'What Is New In PHP 8?',
        body: `In this article we will look at some of the new features offered in version 8 of PHP`,
    },
];


Enter fullscreen mode Exit fullscreen mode

Next, create a seed.ts file in the prisma folder with this configuration using PrismaClient:



import { posts } from './posts';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
    for (let post of posts) {
        await prisma.post.create({
            data: post,
        });
    }
}
main()
    .catch((e) => {
        console.log(e);
        process.exit(1);
    })
    .finally(() => {
        prisma.$disconnect();
    });


Enter fullscreen mode Exit fullscreen mode

This seed.ts file imports the posts array, then uses PrismaClient to iterate through each post in the array and adds it to the database via prisma.post.create().

Next, you need to run the seed.ts file. To run this TypeScript file, install the tsx dependency:



npm i tsx


Enter fullscreen mode Exit fullscreen mode

Then execute the seed.ts file:



tsx prisma/seed.t


Enter fullscreen mode Exit fullscreen mode

Alternatively, you can add a Prisma script to your package.json file:



"prisma": {
    "seed": "tsx prisma/seed.ts"
},


Enter fullscreen mode Exit fullscreen mode

When you run npx prisma db seed, it will populate your Neon database. To check, reload your Neon database, navigate to the Tables in your dashboard, and click the Post table to view the data:

Seeded data showing in Neon database

This approach lets you pre-fill your database with the specified data, ensuring your setup works seamlessly before delving into Remix interactions.

Interacting with Prisma and Neon from Remix

Now that you've set up a Neon Postgres database, Prisma, and some initial data seeding to verify its functionality, the next step involves fetching these posts from Neon into your Remix app. You'll also implement the functionality to add and delete posts.

Start by creating a server file responsible for establishing a database connection using Prisma:



import { PrismaClient } from '@prisma/client';

let db: PrismaClient;

declare global {
    var __db: PrismaClient | undefined;
}

if (process.env.NODE_ENV == 'production') {
    db = new PrismaClient();
    db.$connect();
} else {
    if (!global.__db) {
        global.__db = new PrismaClient();
        global.__db.$connect();
    }
    db = global.__db;
}

export { db };


Enter fullscreen mode Exit fullscreen mode

This code ensures a consistent database connection throughout the application's lifecycle. In production, it directly establishes a new connection; in other environments, it maintains a single global connection for efficiency.

Loading posts with Remix loader

In the posts._index.tsx file, to access the database, import the database schema, and update the posts array:



import { useLoaderData, Link } from '@remix-run/react';

import { db } from '~/utils/db.server';
// Define a type for a single post
type Post = {
    id: string;
    title: string;
    body: string;
    createdAt: Date;
};

// Define a type for the data returned by the loader
type LoaderData = {
    posts: Post[];
};

export const loader = async () => {
    const data = {
        posts: await db.post.findMany(),
    };

    return data;
};


Enter fullscreen mode Exit fullscreen mode

This code sets up a loader function to fetch an array of posts from the database using db.post.findMany(), defining types for the fetched data. The fetched posts data is then made available to components through the loader.



const Posts = () => {
    const { posts } = useLoaderData<LoaderData>();

    return (
        <>
            <div className="page-header">
                <h1>Posts</h1>
                <Link to="/posts/new" className="btn">
                    New Post
                </Link>
            </div>
            <ul className="posts-list">
                {posts.map((post) => (
                    <li key={post.id}>
                        <Link to={post.id}>
                            <h3>{post.title}</h3>
                            {new Date(post.createdAt).toLocaleString()}
                        </Link>
                    </li>
                ))}
            </ul>
        </>
    );
};

export default Posts;


Enter fullscreen mode Exit fullscreen mode

This will display all the posts stored in the Postgres database on your Remix application.

All posts fetched from Neon via Prisma and displayed in Remix application

Load a single post from the database

In the posts.$postid.tsx file, which serves as a dynamic route to display each post based on its ID, you'll implement the retrieval of dynamic data from the database:

In the loader function, you’ll use the findUnique() method by attaching the where parameter indicating the ID for the post to be fetched:



import type { LoaderFunction } from '@remix-run/node';
import { useLoaderData, Link, Form } from '@remix-run/react';
import { db } from '~/utils/db.server';

export const loader: LoaderFunction = async ({ params }) => {
    const post = await db.post.findUnique({
        where: { id: params.postid },
    });

    if (!post) throw new Error('Post not found');

    const data = { post };

    return data;
};


Enter fullscreen mode Exit fullscreen mode

You can then use the useLoaderData function to populate the client side of the application:



interface Post {
    title: string;
    body: string;
    // Define other properties if available in the 'post' object
}

const Postid = () => {
    const { post }: { post: Post } = useLoaderData();

    return (
        <>
            <div className="page-header">
                <h2>{post.title}</h2>
                <Link to="/posts" className="btn btn-reverse">
                    Back
                </Link>
            </div>
            <div className="page-content">{post.body}</div>
            <div className="page-footer">
                <button className="btn btn-delete">Delete</button>
            </div>
        </>
    );
};

export default Postid;


Enter fullscreen mode Exit fullscreen mode

When you click any post from the Posts index page, it will redirect you to this dynamic page with the unique data.

Single posts fetched via it’s unique ID

Deleting posts with Remix action

Implementing the Delete button on the dynamic page involves steps similar to those used to fetch a dynamic post. However, this time, action is utilized. You’ll wrap the button in a form such that a post request can be submitted to trigger the button action:



<div className="page-footer">
    <Form method="POST">
        <input type="hidden" name="_method" value="delete" />
        <button className="btn btn-delete">Delete</button>
    </Form>
</div>


Enter fullscreen mode Exit fullscreen mode

The form sends a POST request with a hidden input field (_method) set to delete to indicate the intention to delete the post. This is what the server-side code will look like:



import type { ActionFunction } from '@remix-run/node';
import { redirect } from '@remix-run/node';

import { db } from '~/utils/db.server';

export const action: ActionFunction = async ({ request, params }) => {
    const form = await request.formData();

    if (form.get('_method') === 'delete') {
        const post = await db.post.findUnique({
            where: { id: params.postid },
        });
        if (!post) throw new Error('Post not found');
        await db.post.delete({
            where: { id: params.postid },
        });
        return redirect('/posts');
    }
};


Enter fullscreen mode Exit fullscreen mode

On the server side, when a POST request is received, it checks that it contains a _method field set to "delete". If found, it retrieves the post based on the specified postid, ensures its existence, and then proceeds to delete it from the database using db.post.delete. Finally, it redirects the user to the /posts page after successful deletion.

This approach follows a RESTful convention, utilizing a POST request with a hidden field to simulate a DELETE request, enabling the server to process the delete action accordingly.

When you click the delete button, the post will be deleted from the database and on the client, redirecting you to the posts index page.

Adding a new post with Remix action

Finally, one prominent feature of every full-stack application is being able to add new information from the client side to your database. This can be done with the create() method, specifying the form fields:



import type { ActionFunction } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { Link, Form } from '@remix-run/react';
import { db } from '~/utils/db.server';

export const action: ActionFunction = async ({ request }) => {
    const form = await request.formData();
    const title = form.get('title') ?? '';
    const body = form.get('body') ?? '';
    const fields = {
        title: String(title),
        body: String(body),
    };

    const post = await db.post.create({ data: fields });

    return redirect(`/posts/${post.id}`);
};


Enter fullscreen mode Exit fullscreen mode

The code above utilizes Remix's action function to handle form submissions, extracting title and body data from a client request and then creating a new post in the database using this information. Upon successful creation, it redirects the user to the newly generated post's URL.

Conclusion

This guide explores building a full-stack application with Remix utilizing Prisma and Neon. This full-stack approach, incorporating Remix's capabilities, Prisma's robust ORM, and Neon's database agility, provides a solid foundation for building modern, scalable web applications.

You can access the complete code for this application on GitHub. This application can incorporate more features, like the update post feature. Read through the Neon and Prisma documentation to learn more.

Have fun coding!

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