Logo

ElysiaJS Integration in Next.js

Discover the benefits of using ElysiaJS as your backend for Next.js applications. Explore various integration methods and typesafe api calls.

  • Next.js
  • ElysiaJS
  • Prisma

Introduction

Hello everynyan! Today, we're diving into the world of full-stack web development using Next.js and Elysia.js, two powerful frameworks that streamline frontend and backend development, respectively.

Installation

  1. Create a new Next.js project, you can use my template by running this command:
npx create-t3-app
  1. Install dependencies
npm install bun add -d prisma
pnpm add bun add -d prisma
yarn add bun add -d prisma
bun add bun add -d prisma
bun add @prisma/client elysia @elysiajs/eden superjson @tanstack/react-query
  1. Create a new model in prisma/schema.prisma
prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

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

model Post {
  id      String @id @default(cuid())
  content String

  createdAt DateTime @default(now())
}

Note: you can run your postgresql database with docker by this command

docker run --name yuki-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres
cp .env.example .env

# .env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/next-elysia"

If you want to add other variables, remember to add it to env.js to validate it

Next, add this script to your package.json

package.json
{
  "scripts": {
    "db:push": "prisma db push",
    "db:studio": "prisma studio",
    "postinstall": "prisma generate"
  }
}

Then, push schema to your database

bun db:push

Backend Integration

  1. Create a Prisma service

To connect to the database, we need to create a Prisma service. This service will be used to interact with the database.

server/db.ts
import { PrismaClient } from '@prisma/client'

import { env } from '@/env'

const createPrismaClient = () =>
  new PrismaClient({
    log:
      env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
  })

const globalForPrisma = globalThis as unknown as {
  prisma: ReturnType<typeof createPrismaClient> | undefined
}

export const db = globalForPrisma.prisma ?? createPrismaClient()

if (env.NODE_ENV !== 'production') globalForPrisma.prisma = db
  1. Create a configuration file for ElysiaJS
server/api/elysia.ts
import type { ElysiaConfig } from 'elysia'
import Elysia from 'elysia'

import { db } from '@/server/db'

/**
 * 1. CONTEXT
 *
 * This section defines the "contexts" that are available in the backend API.
 *
 * These allow you to access things when processing a request, like the database, the session, etc.
 *
 * This helper generates the "internals" for a tRPC context. The API handler and RSC clients each
 * wrap this and provides the required context.
 *
 * @see https://trpc.io/docs/server/context
 */
export const createElysiaContext = new Elysia()
  .derive(async () => {
    return { ctx: { db, session } }
  })
  .decorate('ctx', { db })
  .as('plugin')

/**
 * 2. INITIALIZATION
 *
 * This is where the elysia API is initialized, connecting the context and transformer. We also parse
 * ZodErrors so that you get typesafety on the frontend if your procedure fails due to validation
 * errors on the backend.
 */
export const elysia = <P extends string, S extends boolean>(
  options?: ElysiaConfig<P, S>,
) => new Elysia(options).use(createElysiaContext)

/**
 * Middleware for timing procedure execution and adding an artificial delay in development.
 *
 * You can remove this if you don't like it, but it can help catch unwanted waterfalls by simulating
 * network latency that would occur in production but not in local development.
 */
const timmingMiddleware = new Elysia()
  .state({ start: 0 })
  .onBeforeHandle(({ store }) => (store.start = Date.now()))
  .onAfterHandle(({ path, store: { start } }) =>
    console.log(`[Elysia] ${path} took ${Date.now() - start}ms to execute`),
  )
  .as('plugin')

/**
 * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT)
 *
 * These are the pieces you use to build your elysia API. You should import these a lot in the
 * "/src/server/api/routers" directory.
 */
export const createElysiaRouter = <P extends string, S extends boolean>(
  options?: ElysiaConfig<P, S>,
) => elysia(options).use(timmingMiddleware)
  1. Create a new API endpoint

In this blog post, we will create a simple API endpoint to fetch latest posts and create a new post in the database.

server/api/routes/post.ts
import { t } from 'elysia'

import { createElysiaRouter } from '@/server/api/elysia'

export const postRouter = createElysiaRouter({ prefix: '/post' })
  .get('/getLatestPost', async ({ ctx }) => {
    const post = await ctx.db.post.findFirst({
      orderBy: { createdAt: 'desc' },
    })
    return post ?? null
  })
  // .onBeforeHandle(({ ctx, error }) => {
  //   if (!ctx.session) return error('Unauthorized')
  // })
  .post(
    '/createPost',
    async ({ ctx, body }) => {
      return ctx.db.post.create({ data: { content: body.content } })
    },
    {
      body: t.Object({ content: t.String() }),
    },
  )

Then, add this route to your server

server/api/root.ts
import type {
  InferTreatyQueryInput,
  InferTreatyQueryOutput,
} from '@ap0nia/eden-react-query'
import { treaty } from '@elysiajs/eden'

import { elysia } from '@/server/api/elysia'
import { postRouter } from '@/server/api/routers/post'

/**
 * This is the primary router for your server.
 *
 * All routers added in /api/routers should be manually added here.
 */
const baseAppRouter = elysia({ prefix: '' })
  // .use(edenPlugin({ batch: true, transformer: SuperJSON })) // make error
  .use(postRouter)

const appRouter = elysia({ prefix: '/api/elysia' }).use(baseAppRouter)

// export type definition of API
type AppRouter = typeof baseAppRouter

/**
 * Create a server-side caller for the tRPC API.
 * @example
 * const elysia = createCaller(createContext);
 * const res = await elysia.post.all();
 *       ^? Post[]
 */
const createCaller = treaty(appRouter)

/**
 * Inference helpers for input types
 * @example
 * type PostByIdInput = RouterInputs['post']['byId']
 *      ^? { id: number }
 **/
// @ts-expect-error - lgtm
type RouterInputs = InferTreatyQueryInput<AppRouter>

/**
 * Inference helpers for output types
 * @example
 * type AllPostsOutput = RouterOutputs['post']['all']
 *      ^? Post[]
 **/
// @ts-expect-error - lgtm
type RouterOutputs = InferTreatyQueryOutput<AppRouter>

export { appRouter, createCaller }
export type { AppRouter, RouterInputs, RouterOutputs }
  1. Add the API endpoint to your Next.js application
app/api/elysia/[[...slug]]/route.ts
import { appRouter } from '@/server/api/root'

const handler = appRouter.handle

export { handler as GET, handler as POST, handler as PUT, handler as DELETE }

API Integration

In this section, we will create a function to interact with the API endpoints we created earlier on both the server and client sides. In client side, we will use @tanstack/react-query to cache the data.

  1. Create a server-side caller
lib/elysia/server.ts
import 'server-only'

import { createCaller } from '@/server/api/root'

export const api = createCaller.api.elysia
  1. Create a client-side caller

First, create a query client configuration for @tanstack/react-query

lib/elysia/query-client.ts
import { defaultShouldDehydrateQuery, QueryClient } from '@tanstack/react-query'
import SuperJSON from 'superjson'

export const createQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
      dehydrate: {
        serializeData: SuperJSON.serialize,
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === 'pending',
      },
      hydrate: {
        deserializeData: SuperJSON.deserialize,
      },
    },
  })

Then, create a client-side caller

lib/elysia/react.tsx
'use client'

import type { QueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import {
  createEdenTreatyReactQuery,
  httpBatchLink,
} from '@ap0nia/eden-react-query'
import { QueryClientProvider } from '@tanstack/react-query'

import type { AppRouter } from '@/server/api/root'
import { createQueryClient } from '@/lib/elysia/query-client'
import { getBaseUrl } from '@/lib/utils'

let clientQueryClientSingleton: QueryClient | undefined = undefined
const getQueryClient = () => {
  if (typeof window === 'undefined') {
    // Server: always make a new query client
    return createQueryClient()
  }
  // Browser: use singleton pattern to keep the same query client
  return (clientQueryClientSingleton ??= createQueryClient())
}

// @ts-expect-error - lgtm
export const api = createEdenTreatyReactQuery<AppRouter>({
  abortOnUnmount: true,
})

export const ElysiaReactProvider: React.FC<React.PropsWithChildren> = ({
  children,
}) => {
  const queryClient = getQueryClient()

  const [elysiaClient] = useState(() =>
    api.createClient({
      links: [
        // @ts-expect-error - lgtm
        httpBatchLink({
          domain: getBaseUrl() + '/api/elysia',
        }),
      ],
    }),
  )

  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={elysiaClient} queryClient={queryClient}>
        {children}
      </api.Provider>
    </QueryClientProvider>
  )
}
  1. Frontend Integration

Now, we can use the api object to interact with the API endpoints we created earlier. Here's an example of how to fetch the latest post and create a new post.

app/_components/post.tsx
'use client'

import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { api } from '@/lib/elysia/react'

export const Post: React.FC = () => {
  const [latestPost, { refetch }] =
    api.post.getLatestPost.get.useSuspenseQuery()
  const createPost = api.post.createPost.post.useMutation({
    onSuccess: () => refetch(),
  })

  return (
    <div className="mt-4 w-full max-w-(--breakpoint-sm)">
      <span className="text-lg">
        Latest post: {latestPost?.content ?? 'No posts'}
      </span>
      <form
        className="flex w-full gap-4"
        onSubmit={(e) => {
          e.preventDefault()
          createPost.mutate({
            content: String(new FormData(e.currentTarget).get('content')),
          })
          e.currentTarget.reset()
        }}
      >
        <Input
          name="content"
          placeholder="Post's content"
          disabled={createPost.isPending}
        />
        <Button disabled={createPost.isPending}>Post</Button>
      </form>
    </div>
  )
}

Conclusion

In this blog post, we explored the benefits of using ElysiaJS as the backend for Next.js applications. We learned how to create a Prisma service, configure ElysiaJS, and create API endpoints to interact with the database. We also created a function to interact with the API endpoints on both the server and client sides. By following these steps, you can build powerful full-stack web applications with ease.

I hope you found this blog post helpful. If you have any questions or feedback, feel free to leave a comment below. Thank you for reading!

Repository: create-elynext-app

References