ElysiaJS Integration in Next.js

Wed Jul 24 2024

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

Next.jsElysiaJSPrisma

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
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