April 18, 2025

Integrating Gel with Drizzle in a Next.js App

Are you looking to build a type-safe, full-stack TypeScript application? In this post, we'll show you how to combine Gel's powerful data layer with Drizzle ORM and Next.js to create a Book Notes app that helps users track their reading and add notes about books they've read.

The complete source code for this project is available in our Gel Examples repository.

Our application uses three main technologies that work nicely together:

  • Gel provides a robust, type-safe database layer with a powerful schema system

  • Drizzle ORM offers a clean TypeScript API for database operations without compromising on type safety

  • Next.js handles our UI and API routes with server components and built-in routing

This combination gives us end-to-end type safety, excellent developer experience, and the ability to build a full-stack application with minimal boilerplate.

But why choose Gel over other PostgreSQL solutions? Gel significantly enhances the core PostgreSQL feature set with a high-level object model that is fully type-safe, intuitive, and offers advanced features like mixins and access control. Additionally, it provides a hierarchical, graph-like query language (EdgeQL), a built-in and free authentication layer, and many other benefits.

Gel truly excels in projects that leverage multiple languages and frameworks on the backend by offering a unified data model and APIs that are both consistent and type-safe across all combinations.

Let's start by setting up our development environment. We'll create a Next.js application with TypeScript support and Tailwind CSS for styling, then add Gel and Drizzle to handle our data layer.

Copy
$ 
# Create Next.js app with TypeScript and Tailwind CSS
Copy
$ 
npx create-next-app@latest book-notes-app
Copy
$ 
cd book-notes-app
Copy
$ 
# Install Gel and Drizzle dependencies
Copy
$ 
npm i gel @gel/generate drizzle-orm drizzle-kit

# Initialize Gel project
Copy
$ 
npx gel project init

When you run gel project init, Gel sets up a local instance and creates necessary configuration files. It also installs Postgres for you. It gives you a development database right out of the box, with no separate database setup required.

With our project initialized, it's time to define our data model. For our Book Notes app, we need two main types:

  1. Book - to store information about books we've read

  2. Note - to store notes associated with each book

Gel's schema language is expressive and intuitive. Let's create our schema:

dbschema/default.gel
Copy
module default {
  type Book {
    required title: str;
    author: str;
    year: int16;
    genre: str;
    read_date: datetime;

    multi notes := .<book[is Note];
  }

  type Note {
    required text: str;
    created_at: datetime {
      default := datetime_current();
    }

    required book: Book;
  }
}

This schema defines a relationship between books and notes. Each note belongs to exactly one book (via the book link), and each book can have multiple notes (via the computed notes link).

The datetime_current() function automatically sets the creation time for new notes.

Now, let's apply this schema to our database:

Copy
$ 
npx gel migration create
Copy
$ 
npx gel migrate

The first command generates a migration file that represents the changes to apply to our database. The second command applies those changes. Gel handles the creation of tables, constraints, and relationships for us.

With our Gel schema in place, it's time to integrate Drizzle ORM. Drizzle will provide a type-safe TypeScript interface to interact with our Gel database.

First, we create a configuration file for Drizzle:

drizzle.config.ts
Copy
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  dialect: 'gel',
});

This tells Drizzle that we're using Gel as our database and specifies where to put generated files.

Next, we pull the Gel schema into Drizzle:

Copy
$ 
npx drizzle-kit pull

The pull command introspects our Gel database and generates TypeScript files that describe our schema for Drizzle. This creates a seamless integration between Gel's schema and Drizzle's query building capabilities. The generated files will be placed in the drizzle directory.

Now, let's create a database client that we'll use throughout our application:

src/db/index.ts
Copy
import { drizzle } from 'drizzle-orm/gel';
import * as schema from '../../drizzle/schema';
import * as relations from '../../drizzle/relations';

// Initialize Gel client
const gelClient = createClient();

// Create Drizzle instance with our schema
export const db = drizzle({
  client: gelClient, schema: {
    ...schema,
    ...relations
  },
});

// Helper types for use in our application
export type Book = typeof schema.books.$inferSelect;
export type NewBook = typeof schema.books.$inferInsert;
export type Note = typeof schema.notes.$inferSelect;
export type NewNote = typeof schema.notes.$inferInsert;

This client connects to our Gel database and exposes the Drizzle API for querying and manipulating data. The helper types we export will be used throughout our application to ensure type safety.

To keep Drizzle in sync with Gel whenever we apply migrations, we'll add a hook in our gel.toml file:

Copy
[hooks]
after_migration_apply = [
  "npx drizzle-kit pull"
]

Now that our data layer is set up, we can create API endpoints using Next.js's App Router. These endpoints will handle CRUD operations for our books and notes.

Let's start with a route for getting all books and adding a new book:

src/app/api/books/route.ts
Copy
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { book } from '@/drizzle/schema';

export async function GET() {
  try {
    // Fetch all books with their notes
    const allBooks = await db.query.book.findMany({
      with: { notes: true },
    });

    return NextResponse.json(allBooks);
  } catch (error) {
    console.error('Error fetching books:', error);
    return NextResponse.json(
      { error: 'Failed to fetch books' },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    const body = await request.json();

    // Insert a new book
    const result = await db.insert(book).values({
      title: body.title,
      author: body.author,
      year: body.year,
      genre: body.genre,
      readDate: new Date(body.read_date),
    }).returning();

    return NextResponse.json(result[0], { status: 201 });
  } catch (error) {
    console.error('Error adding book:', error);
    return NextResponse.json(
      { error: 'Failed to add book' },
      { status: 500 }
    );
  }
}
Show more

Notice how we're using Drizzle's query builder to interact with our Gel database. The findMany function fetches all books, and the with option includes related notes. The insert function adds a new book to the database.

We also need routes for handling individual books and notes. For example, here's how we would handle getting, updating, and deleting a specific book:

src/app/api/books/[id]/route.ts
Copy
import { NextResponse } from 'next/server';
import { db } from '@/db';
import { book, note } from '@/drizzle/schema';
import { eq } from 'drizzle-orm';

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;
  try {
    // Find a book by ID with its notes
    const book = await db.query.book.findFirst({
      where: eq(book.id, id),
      with: { notes: true },
    });

    if (!book) {
      return NextResponse.json(
        { error: 'Book not found' },
        { status: 404 }
      );
    }

    return NextResponse.json(book);
  } catch (error) {
    console.error('Error fetching book:', error);
    return NextResponse.json(
      { error: 'Failed to fetch book' },
      { status: 500 }
    );
  }
}

// Similar implementations for PUT and DELETE...
Show more

With our API routes in place, we can build the user interface for our application. We'll use React components with Tailwind CSS for styling.

Let's start with the home page that displays a list of all books:

app/page.tsx
Copy
'use client';

import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Book } from '@/db';

export default function Home() {
  const [books, setBooks] = useState<Book[]>([]);
  const [loading, setLoading] = useState(true);

  // Fetch books when the component mounts
  useEffect(() => {
    async function fetchBooks() {
      try {
        const response = await fetch('/api/books');
        if (!response.ok) throw new Error('Failed to fetch books');
        const data = await response.json();
        setBooks(data);
      } catch (error) {
        console.error('Error:', error);
      } finally {
        setLoading(false);
      }
    }

    fetchBooks();
  }, []);

  if (loading) {
    return (
      <div className="flex justify-center items-center min-h-screen">
        <p className="text-xl">Loading...</p>
      </div>
    );
  }

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">My Book Notes</h1>

      <Link href="/books/add" className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded mb-6 inline-block">
        Add New Book
      </Link>

      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-6">
        {books.length === 0 ? (
          <p>No books found. Add your first book!</p>
        ) : (
          books.map((book) => (
            <div key={book.id} className="border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow">
              <h2 className="text-xl font-semibold">{book.title}</h2>
              {book.author && <p className="text-gray-600">by {book.author}</p>}
              {book.year && <p className="text-sm text-gray-500">Published: {book.year}</p>}
              {book.genre && <p className="text-sm text-gray-500">Genre: {book.genre}</p>}
              <p className="mt-2 text-sm">
                {book.notes?.length || 0} notes
              </p>
              <Link href={`/books/${book.id}`} className="mt-3 text-blue-500 hover:text-blue-600 inline-block">
                View Details
              </Link>
            </div>
          ))
        )}
      </div>
    </main>
  );
}
Show more

This component fetches all books from our API and displays them in a responsive grid. Each book card shows basic information and a link to view more details.

For a complete application, we also need forms for adding and editing books, a book details page, and components for managing notes. These follow a similar pattern:

  1. Fetch data from our API

  2. Manage state with React hooks

  3. Render UI with Tailwind CSS

  4. Handle user interactions (form submissions, button clicks, etc.)

Since these components follow the same patterns, we won't show all of them here. You can find the complete implementation in our GitHub repository.

The combination of Gel, Drizzle, and Next.js provides several key advantages:

  1. End-to-end type safety: Types flow from our Gel schema through Drizzle and into our React components. This catches errors at compile time rather than runtime.

  2. Developer productivity: Each tool excels at its specific job - Gel for data modeling, Drizzle for query building, and Next.js for UI and routing. This leads to cleaner, more maintainable code.

  3. Scalability: This architecture can grow with your application's needs. You can add authentication, more complex data models, and additional features without changing the core.

There are many ways to extend this application:

  • Add authentication to support multiple users

  • Implement filtering and searching for books

  • Create a statistics dashboard for reading habits

  • Add import/export functionality

  • Implement full-text search for notes

In this post, we've seen how to build a Book Notes application using Gel, Drizzle, and Next.js. This combination provides a powerful, type-safe foundation for full-stack TypeScript applications.

The key takeaways are:

  • Gel provides a robust foundation for your data layer with its powerful schema system

  • Drizzle offers a type-safe and intuitive way to interact with your Gel database

  • Next.js makes it easy to build both API routes and UI components

  • Together, these tools create a seamless developer experience

Ready to try it yourself? Check out the complete example code or explore our documentation to learn more about Gel.

Want to dive deeper? We've created a comprehensive step-by-step tutorial in our documentation that walks you through building this application from scratch, with detailed explanations and full code examples.

Happy coding!

ShareTweet