From 4f52537ea072067ee38e2e7552bcaa16ea8a48ec Mon Sep 17 00:00:00 2001 From: dswbx Date: Tue, 4 Mar 2025 14:39:32 +0100 Subject: [PATCH] updated nextjs example --- app/src/adapter/nextjs/nextjs.adapter.ts | 11 ++ app/src/core/utils/runtime.ts | 8 ++ docs/integration/nextjs.mdx | 131 ++++++++++-------- docs/integration/remix.mdx | 5 +- examples/astro/src/pages/api/[...api].ts | 31 +++-- examples/nextjs/src/app/(main)/Footer.tsx | 36 +++++ examples/nextjs/src/app/(main)/layout.tsx | 76 ++++++++++ examples/nextjs/src/app/(main)/page.tsx | 91 ++++++++++++ examples/nextjs/src/app/(main)/ssr/page.tsx | 33 +++++ .../nextjs/src/app/api/[[...bknd]]/route.ts | 17 ++- examples/nextjs/src/app/env/route.ts | 3 - examples/nextjs/src/app/page.tsx | 97 ------------- examples/nextjs/src/app/ssr/page.tsx | 14 -- examples/nextjs/src/bknd.ts | 68 ++++++++- examples/remix/app/bknd.ts | 30 ++-- 15 files changed, 440 insertions(+), 211 deletions(-) create mode 100644 examples/nextjs/src/app/(main)/Footer.tsx create mode 100644 examples/nextjs/src/app/(main)/layout.tsx create mode 100644 examples/nextjs/src/app/(main)/page.tsx create mode 100644 examples/nextjs/src/app/(main)/ssr/page.tsx delete mode 100644 examples/nextjs/src/app/env/route.ts delete mode 100644 examples/nextjs/src/app/page.tsx delete mode 100644 examples/nextjs/src/app/ssr/page.tsx diff --git a/app/src/adapter/nextjs/nextjs.adapter.ts b/app/src/adapter/nextjs/nextjs.adapter.ts index 4b4fe44..5c2a364 100644 --- a/app/src/adapter/nextjs/nextjs.adapter.ts +++ b/app/src/adapter/nextjs/nextjs.adapter.ts @@ -1,5 +1,6 @@ import type { App } from "bknd"; import { type FrameworkBkndConfig, createFrameworkApp } from "bknd/adapter"; +import { getRuntimeKey, isNode } from "core/utils"; export type NextjsBkndConfig = FrameworkBkndConfig & { cleanRequest?: { searchParams?: string[] }; @@ -31,6 +32,16 @@ function getCleanRequest(req: Request, cleanRequest: NextjsBkndConfig["cleanRequ const url = new URL(req.url); cleanRequest?.searchParams?.forEach((k) => url.searchParams.delete(k)); + if (isNode()) { + return new Request(url.toString(), { + method: req.method, + headers: req.headers, + body: req.body, + // @ts-ignore + duplex: "half", + }); + } + return new Request(url.toString(), { method: req.method, headers: req.headers, diff --git a/app/src/core/utils/runtime.ts b/app/src/core/utils/runtime.ts index 311cef3..3e1bfa1 100644 --- a/app/src/core/utils/runtime.ts +++ b/app/src/core/utils/runtime.ts @@ -39,3 +39,11 @@ export function runtimeSupports(feature: keyof typeof features) { return features[feature]; } + +export function isNode() { + try { + return global?.process?.release?.name === "node"; + } catch (e) { + return false; + } +} diff --git a/docs/integration/nextjs.mdx b/docs/integration/nextjs.mdx index 2d4ed85..03e43c4 100644 --- a/docs/integration/nextjs.mdx +++ b/docs/integration/nextjs.mdx @@ -23,78 +23,97 @@ To get started with Next.js and bknd you can either install the package manually ## Serve the API -``` tsx -// pages/api/[...route].ts -import { serve } from "bknd/adapter/nextjs"; +Create a helper file to instantiate the bknd instance and retrieve the API: + +```ts src/bknd.ts +import { type NextjsBkndConfig, getApp as getBkndApp } from "bknd/adapter/nextjs"; +import { headers } from "next/headers"; export const config = { - runtime: "edge", // or "experimental-edge", depending on your nextjs version - unstable_allowDynamic: ["**/*.js"] -}; - -export default serve({ connection: { - url: process.env.DB_URL!, - authToken: process.env.DB_AUTH_TOKEN! + url: "file:data.db" + }, +} as const satisfies NextjsBkndConfig; + +export async function getApp() { + return await getBkndApp(config); +} + +export async function getApi(opts?: { verify?: boolean }) { + const app = await getApp(); + if (opts?.verify) { + const api = app.getApi({ headers: await headers() }); + await api.verifyAuth(); + return api; } -}); + + return app.getApi(); +} ``` For more information about the connection object, refer to the [Database](/usage/database) guide. +Now to expose the API, create a catch-all route file at `src/api/[[...bknd]]/route.ts`: +```ts src/api/[[...bknd]]/route.ts +import { config } from "@/bknd"; +import { serve } from "bknd/adapter/nextjs"; + +// optionally, you can set the runtime to edge for better performance +export const runtime = "edge"; + +const handler = serve({ + ...config, + cleanRequest: { + // depending on what name you used for the catch-all route, + // you need to change this to clean it from the request. + searchParams: ["bknd"], + }, +}); + +export const GET = handler; +export const POST = handler; +export const PUT = handler; +export const PATCH = handler; +export const DELETE = handler; +``` + ## Enabling the Admin UI -Create a file `[[...admin]].tsx` inside the `pages/admin` folder: -```tsx -// pages/admin/[[...admin]].tsx -import type { InferGetServerSidePropsType as InferProps } from "next"; -import { withApi } from "bknd/adapter/nextjs"; -import dynamic from "next/dynamic"; +Create a page at `admin/[[...admin]]/page.tsx`: +```tsx admin/[[...admin]]/page.tsx +import { Admin } from "bknd/ui"; +import { getApi } from "@/bknd"; import "bknd/dist/styles.css"; -const Admin = dynamic(() => import("bknd/ui").then((mod) => mod.Admin), { - ssr: false, -}); +export default async function AdminPage() { + // make sure to verify auth using headers + const api = await getApi({ verify: true }); -export const getServerSideProps = withApi(async (context) => { - return { - props: { - user: context.api.getUser(), - }, - }; -}); - -export default function AdminPage({ user }: InferProps) { - if (typeof document === "undefined") return null; - return ; + return ( + + ); } ``` -## Example usage of the API in pages dir -Using pages dir, you need to wrap the `getServerSideProps` function with `withApi` to get access -to the API. With the API, you can query the database or retrieve the authentication status: -```tsx -import { withApi } from "bknd/adapter/nextjs"; -import type { InferGetServerSidePropsType as InferProps } from "next"; +## Example usage of the API +You can use the `getApi` helper function we've already set up to fetch and mutate in static pages and server components: -export const getServerSideProps = withApi(async (context) => { - const { data = [] } = await context.api.data.readMany("todos"); - const user = context.api.getUser(); +```tsx app/page.tsx +import { getApi } from "@/bknd"; - return { props: { data, user } }; -}); +export default async function Home() { + const api = await getApi(); + const { data: todos } = await api.data.readMany("todos", { limit: 5 }); -export default function Home(props: InferProps) { - const { data, user } = props; - return ( -
-

Data

-
{JSON.stringify(data, null, 2)}
- -

User

-
{JSON.stringify(user, null, 2)}
-
- ); + return
    + {todos.map((todo) => ( +
  • {todo.title}
  • + ))} +
} ``` \ No newline at end of file diff --git a/docs/integration/remix.mdx b/docs/integration/remix.mdx index ce823a1..48328f5 100644 --- a/docs/integration/remix.mdx +++ b/docs/integration/remix.mdx @@ -24,7 +24,8 @@ To get started with Remix and bknd you can either install the package manually, ## Serve the API -Since Remix doesn't support middleware yet, we need a helper file to initialize the App to import from. Create a new file at `app/bknd.ts`: +Create a helper file to instantiate the bknd instance and retrieve the API: + ```ts app/bknd.ts import { type RemixBkndConfig, getApp as getBkndApp } from "bknd/adapter/remix"; @@ -49,6 +50,7 @@ export async function getApi(args?: { request: Request }) { return app.getApi(); } ``` +For more information about the connection object, refer to the [Database](/usage/database) guide. Create a new api splat route file at `app/routes/api.$.ts`: ```ts app/routes/api.$.ts @@ -62,7 +64,6 @@ const handler = async (args: { request: Request }) => { export const loader = handler; export const action = handler; ``` -For more information about the connection object, refer to the [Database](/usage/database) guide. Now make sure that you wrap your root layout with the `ClientProvider` so that all components share the same context. Also add the user context to both the `Outlet` and the provider: ```tsx app/root.tsx diff --git a/examples/astro/src/pages/api/[...api].ts b/examples/astro/src/pages/api/[...api].ts index 5d48762..16472ca 100644 --- a/examples/astro/src/pages/api/[...api].ts +++ b/examples/astro/src/pages/api/[...api].ts @@ -14,8 +14,8 @@ registerLocalMediaAdapter(); const schema = em({ todos: entity("todos", { title: text(), - done: boolean() - }) + done: boolean(), + }), }); // register your schema to get automatic type completion @@ -27,7 +27,7 @@ declare module "bknd/core" { export const ALL = serve({ // we can use any libsql config, and if omitted, uses in-memory connection: { - url: "file:data.db" + url: "file:data.db", }, // an initial config is only applied if the database is empty initialConfig: { @@ -36,8 +36,9 @@ export const ALL = serve({ auth: { enabled: true, jwt: { - secret: secureRandomString(64) - } + issuer: "bknd-astro-example", + secret: secureRandomString(64), + }, }, // ... and media media: { @@ -45,19 +46,19 @@ export const ALL = serve({ adapter: { type: "local", config: { - path: "./public" - } - } - } + path: "./public", + }, + }, + }, }, options: { // the seed option is only executed if the database was empty seed: async (ctx) => { await ctx.em.mutator("todos").insertMany([ { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false } + { title: "Build something cool", done: false }, ]); - } + }, }, // here we can hook into the app lifecycle events ... beforeBuild: async (app) => { @@ -66,11 +67,11 @@ export const ALL = serve({ async () => { // ... to create an initial user await app.module.auth.createUser({ - email: "ds@bknd.io", - password: "12345678" + email: "test@bknd.io", + password: "12345678", }); }, - "sync" + "sync", ); - } + }, }); diff --git a/examples/nextjs/src/app/(main)/Footer.tsx b/examples/nextjs/src/app/(main)/Footer.tsx new file mode 100644 index 0000000..e1d3c9b --- /dev/null +++ b/examples/nextjs/src/app/(main)/Footer.tsx @@ -0,0 +1,36 @@ +"use client"; + +import Image from "next/image"; +import { usePathname } from "next/navigation"; + +export function Footer() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/examples/nextjs/src/app/(main)/layout.tsx b/examples/nextjs/src/app/(main)/layout.tsx new file mode 100644 index 0000000..6c95ea7 --- /dev/null +++ b/examples/nextjs/src/app/(main)/layout.tsx @@ -0,0 +1,76 @@ +import Image from "next/image"; +import type { ReactNode } from "react"; +import { Footer } from "./Footer"; + +export default async function Layout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
+ Next.js logo +
&
+ bknd logo +
+ + {children} +
+
+
+ ); +} + +export const Buttons = () => ( + +); + +export const List = ({ items = [] }: { items: ReactNode[] }) => ( +
    + {items.map((item, i) => ( +
  1. + {item} +
  2. + ))} +
+); diff --git a/examples/nextjs/src/app/(main)/page.tsx b/examples/nextjs/src/app/(main)/page.tsx new file mode 100644 index 0000000..c7e2fef --- /dev/null +++ b/examples/nextjs/src/app/(main)/page.tsx @@ -0,0 +1,91 @@ +import { getApi } from "@/bknd"; +import { revalidatePath } from "next/cache"; +import { Fragment } from "react"; +import { List } from "@/app/(main)/layout"; + +export default async function Home() { + // without "{ verify: true }", this page can be static + const api = await getApi(); + const limit = 5; + const todos = await api.data.readMany("todos", { limit, sort: "-id" }); + const total = todos.body.meta.total; + + return ( + <> + +
+

+ What's next? +

+
+ {total > limit && ( +
+ {total - limit} more todo(s) hidden +
+ )} +
+ {todos.reverse().map((todo) => ( +
+
+ { + "use server"; + const api = await getApi(); + await api.data.updateOne("todos", todo.id, { + done: !todo.done, + }); + revalidatePath("/"); + }} + /> +
{todo.title}
+
+ +
+ ))} +
+
t.id).join()} + action={async (formData: FormData) => { + "use server"; + const title = formData.get("title") as string; + const api = await getApi(); + await api.data.createOne("todos", { title }); + revalidatePath("/"); + }} + > + + +
+
+
+ + ); +} + +const Description = () => ( + +); diff --git a/examples/nextjs/src/app/(main)/ssr/page.tsx b/examples/nextjs/src/app/(main)/ssr/page.tsx new file mode 100644 index 0000000..979cd3b --- /dev/null +++ b/examples/nextjs/src/app/(main)/ssr/page.tsx @@ -0,0 +1,33 @@ +import { getApi } from "@/bknd"; +import { Buttons, List } from "../layout"; + +export default async function SSRPage() { + const api = await getApi({ verify: true }); + const { data } = await api.data.readMany("todos"); + const user = api.getUser(); + + return ( + <> + todo.title)} /> + + +

+ {user ? ( + <> + Logged in as {user.email}.{" "} + + Logout + + + ) : ( + <> + Not logged in.{" "} + + Login + + + )} +

+ + ); +} diff --git a/examples/nextjs/src/app/api/[[...bknd]]/route.ts b/examples/nextjs/src/app/api/[[...bknd]]/route.ts index d88e7f7..1c7efe4 100644 --- a/examples/nextjs/src/app/api/[[...bknd]]/route.ts +++ b/examples/nextjs/src/app/api/[[...bknd]]/route.ts @@ -1,13 +1,16 @@ -import { getApp } from "@/bknd"; +import { config } from "@/bknd"; +import { serve } from "bknd/adapter/nextjs"; -// if you're not using a local media adapter, or file database, -// you can uncomment this line to enable running bknd on edge +// since we're using the local media adapter in this example, +// we can't use the edge runtime. // export const runtime = "edge"; -const handler = async (request: Request) => { - const app = await getApp(); - return app.fetch(request); -}; +const handler = serve({ + ...config, + cleanRequest: { + searchParams: ["bknd"], + }, +}); export const GET = handler; export const POST = handler; diff --git a/examples/nextjs/src/app/env/route.ts b/examples/nextjs/src/app/env/route.ts deleted file mode 100644 index c2b6dc2..0000000 --- a/examples/nextjs/src/app/env/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const GET = async (req: Request) => { - return Response.json(process.env); -}; diff --git a/examples/nextjs/src/app/page.tsx b/examples/nextjs/src/app/page.tsx deleted file mode 100644 index c6685d2..0000000 --- a/examples/nextjs/src/app/page.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import Image from "next/image"; -import { getApi } from "@/bknd"; - -export default async function Home() { - const api = await getApi(); - - return ( -
-
-
- Next.js logo -
&
- bknd logo -
-
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. Save and see your changes instantly.
  4. -
- - - -
-               {JSON.stringify(await api.data.readMany("posts"), null, 2)}
-            
-
- -
- ); -} diff --git a/examples/nextjs/src/app/ssr/page.tsx b/examples/nextjs/src/app/ssr/page.tsx deleted file mode 100644 index 204ac31..0000000 --- a/examples/nextjs/src/app/ssr/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { getApi } from "@/bknd"; - -export default async function SSRPage() { - const api = await getApi({ verify: true }); - const { data } = await api.data.readMany("posts"); - - return ( -
-

Server-Side Rendered Page

-
{JSON.stringify(data, null, 2)}
-
{JSON.stringify(api.getUser(), null, 2)}
-
- ); -} diff --git a/examples/nextjs/src/bknd.ts b/examples/nextjs/src/bknd.ts index 7914b5f..24061f7 100644 --- a/examples/nextjs/src/bknd.ts +++ b/examples/nextjs/src/bknd.ts @@ -1,5 +1,8 @@ import { type NextjsBkndConfig, getApp as getBkndApp } from "bknd/adapter/nextjs"; +import { App } from "bknd"; +import { boolean, em, entity, text } from "bknd/data"; import { registerLocalMediaAdapter } from "bknd/adapter/node"; +import { secureRandomString } from "bknd/utils"; import { headers } from "next/headers"; // The local media adapter works well in development, and server based @@ -12,10 +15,71 @@ import { headers } from "next/headers"; // For production, it is recommended to uncomment the line below. registerLocalMediaAdapter(); +const schema = em({ + todos: entity("todos", { + title: text(), + done: boolean(), + }), +}); + +// register your schema to get automatic type completion +type Database = (typeof schema)["DB"]; +declare module "bknd/core" { + interface DB extends Database {} +} + export const config = { connection: { - url: process.env.DB_URL as string, - authToken: process.env.DB_TOKEN as string, + url: "file:data.db", + }, + // an initial config is only applied if the database is empty + initialConfig: { + data: schema.toJSON(), + // we're enabling auth ... + auth: { + enabled: true, + jwt: { + issuer: "bknd-nextjs-example", + secret: secureRandomString(64), + }, + cookie: { + pathSuccess: "/ssr", + pathLoggedOut: "/ssr", + }, + }, + // ... and media + media: { + enabled: true, + adapter: { + type: "local", + config: { + path: "./public", + }, + }, + }, + }, + options: { + // the seed option is only executed if the database was empty + seed: async (ctx) => { + await ctx.em.mutator("todos").insertMany([ + { title: "Learn bknd", done: true }, + { title: "Build something cool", done: false }, + ]); + }, + }, + // here we can hook into the app lifecycle events ... + beforeBuild: async (app) => { + app.emgr.onEvent( + App.Events.AppFirstBoot, + async () => { + // ... to create an initial user + await app.module.auth.createUser({ + email: "test@bknd.io", + password: "12345678", + }); + }, + "sync", + ); }, } as const satisfies NextjsBkndConfig; diff --git a/examples/remix/app/bknd.ts b/examples/remix/app/bknd.ts index f0a8ed6..c8fc146 100644 --- a/examples/remix/app/bknd.ts +++ b/examples/remix/app/bknd.ts @@ -10,8 +10,8 @@ registerLocalMediaAdapter(); const schema = em({ todos: entity("todos", { title: text(), - done: boolean() - }) + done: boolean(), + }), }); // register your schema to get automatic type completion @@ -23,7 +23,7 @@ declare module "bknd/core" { const config = { // we can use any libsql config, and if omitted, uses in-memory connection: { - url: "file:test.db" + url: "file:test.db", }, // an initial config is only applied if the database is empty initialConfig: { @@ -33,8 +33,8 @@ const config = { enabled: true, jwt: { issuer: "bknd-remix-example", - secret: secureRandomString(64) - } + secret: secureRandomString(64), + }, }, // ... and media media: { @@ -42,19 +42,19 @@ const config = { adapter: { type: "local", config: { - path: "./public" - } - } - } + path: "./public", + }, + }, + }, }, options: { // the seed option is only executed if the database was empty seed: async (ctx) => { await ctx.em.mutator("todos").insertMany([ { title: "Learn bknd", done: true }, - { title: "Build something cool", done: false } + { title: "Build something cool", done: false }, ]); - } + }, }, // here we can hook into the app lifecycle events ... beforeBuild: async (app) => { @@ -63,13 +63,13 @@ const config = { async () => { // ... to create an initial user await app.module.auth.createUser({ - email: "ds@bknd.io", - password: "12345678" + email: "test@bknd.io", + password: "12345678", }); }, - "sync" + "sync", ); - } + }, } as const satisfies RemixBkndConfig; export async function getApp(args?: { request: Request }) {