|
| 1 | +--- |
| 2 | +title: "Next.js + PowerSync" |
| 3 | +description: "A guide for creating a new Next.js application with PowerSync for offline/local first functionality" |
| 4 | +keywords: ["next.js", "web"] |
| 5 | +--- |
| 6 | + |
| 7 | +## Introduction |
| 8 | +In this tutorial, we’ll explore how to enhance a Next.js application with offline-first capabilities using PowerSync. In the following sections, we’ll walk through the process of integrating PowerSync into a Next.js application, setting up local-first storage, and handling synchronization efficiently. |
| 9 | + |
| 10 | +<Note>At present PowerSync will not work with SSR enabled with Next.js and in this guide we disable SSR across the entire app. However, it is possible to have other pages, which do not require authentication for example, to still be rendered server-side. This can be done by only using the DynamicSystemProvider (covered further down in the guide) for specific pages. This means you can still have full SSR on other page which do not require PowerSync.</Note> |
| 11 | + |
| 12 | +## Setup |
| 13 | + |
| 14 | +### Next.js Project Setup |
| 15 | +Let's start by bootstrapping a new Next.js application using [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). |
| 16 | +```shell |
| 17 | +pnpm dlx create-next-app@latest <project_name> |
| 18 | +``` |
| 19 | + |
| 20 | +When running this command you'll be presented with a few options. The PowerSync suggested selection for the setup options Next.js offers are: |
| 21 | +```shell |
| 22 | +Would you like to use TypeScript? Yes |
| 23 | +Would you like to use ESLint? Yes |
| 24 | +Would you like to use Tailwind CSS? Yes |
| 25 | +Would you like your code inside a `src/` directory? Yes |
| 26 | +Would you like to use App Router? (recommended) Yes |
| 27 | +Would you like to use Turbopack for `next dev`? No |
| 28 | +Would you like to customize the import alias (`@/*` by default)? Yes |
| 29 | +``` |
| 30 | + |
| 31 | +<Warning> |
| 32 | + Do not use Turbopack when setting up a new Next.js project as we’ll be updating the `next.config.ts` to use Webpack. This is done because we need to enable: |
| 33 | + 1. asyncWebAssembly |
| 34 | + 2. topLevelWait |
| 35 | +</Warning> |
| 36 | + |
| 37 | +### Install PowerSync Dependencies |
| 38 | + |
| 39 | +Using PowerSync in a Next.js application will require the use of the [PowerSync Web SDK](https://www.npmjs.com/package/@powersync/web) and it's peer dependencies. |
| 40 | + |
| 41 | +In addition to this we'll also install [`@powersync/react`](https://www.npmjs.com/package/@powersync/react), which provides several hooks and providers for easier integration. |
| 42 | + |
| 43 | +```shell |
| 44 | +pnpm install @powersync/web @journeyapps/wa-sqlite @powersync/react js-logger |
| 45 | +``` |
| 46 | + |
| 47 | +<Note>This SDK currently requires [@journeyapps/wa-sqlite](https://www.npmjs.com/package/@journeyapps/wa-sqlite) as a peer dependency.</Note> |
| 48 | +<Note>Installing `js-logger` is very useful for debugging.</Note> |
| 49 | + |
| 50 | +## Next.js Config Setup |
| 51 | + |
| 52 | +In order for PowerSync to work with the Next.js we'll need to modify the default `next.config.ts` to support PowerSync. |
| 53 | + |
| 54 | +```typescript next.config.ts |
| 55 | +module.exports = { |
| 56 | + experimental: { |
| 57 | + turbo: false, |
| 58 | + }, |
| 59 | + webpack: (config: any, isServer: any) => { |
| 60 | + config.experiments = { |
| 61 | + ...config.experiments, |
| 62 | + asyncWebAssembly: true, // Enable WebAssembly in Webpack |
| 63 | + topLevelAwait: true, |
| 64 | + }; |
| 65 | + |
| 66 | + // For Web Workers, ensure proper file handling |
| 67 | + if (!isServer) { |
| 68 | + config.module.rules.push({ |
| 69 | + test: /\.wasm$/, |
| 70 | + type: "asset/resource", // Adds WebAssembly files to the static assets |
| 71 | + }); |
| 72 | + } |
| 73 | + |
| 74 | + return config; |
| 75 | + } |
| 76 | +} |
| 77 | +``` |
| 78 | + |
| 79 | +Some important notes here, we have to enable `asyncWebAssemply` in Webpack, `topLevelAwait` is required and for Web Workers, ensure proper file handling. |
| 80 | +It's also important to add web assembly files to static assets for the site. We will not be using SSR because PowerSync does not support it. |
| 81 | + |
| 82 | +Run `pnpm dev` to start the development server and check that everything compiles correctly, before moving onto the next section. |
| 83 | + |
| 84 | +## Configure a PowerSync Instance |
| 85 | +Now that we've got our project setup, let's create a new PowerSync Cloud instance and connect our client to it. |
| 86 | +For the purposes of this demo, we'll be using Supabase as the source backend database that PowerSync will connect to. |
| 87 | + |
| 88 | +To set up a new PowerSync instance, follow the steps covered in the [Installation - Database Connection](/installation/database-connection) docs page. |
| 89 | + |
| 90 | +## Configure PowerSync in your project |
| 91 | +### Add core PowerSync files |
| 92 | +Start by adding a new directory in `./src/lib` named `powersync`. |
| 93 | + |
| 94 | +#### `AppSchema` |
| 95 | +Create a new file called `AppSchema.ts` in the newly created `powersync` directory and add your App Schema to the file. Here is an example of this. |
| 96 | +```typescript lib/powersync/AppSchema.ts |
| 97 | +import { column, Schema, Table } from '@powersync/web'; |
| 98 | + |
| 99 | +const lists = new Table({ |
| 100 | + created_at: column.text, |
| 101 | + name: column.text, |
| 102 | + owner_id: column.text |
| 103 | +}); |
| 104 | + |
| 105 | +const todos = new Table( |
| 106 | + { |
| 107 | + list_id: column.text, |
| 108 | + created_at: column.text, |
| 109 | + completed_at: column.text, |
| 110 | + description: column.text, |
| 111 | + created_by: column.text, |
| 112 | + completed_by: column.text, |
| 113 | + completed: column.integer |
| 114 | + }, |
| 115 | + { indexes: { list: ['list_id'] } } |
| 116 | +); |
| 117 | + |
| 118 | +export const AppSchema = new Schema({ |
| 119 | + todos, |
| 120 | + lists |
| 121 | +}); |
| 122 | + |
| 123 | +// For types |
| 124 | +export type Database = (typeof AppSchema)['types']; |
| 125 | +export type TodoRecord = Database['todos']; |
| 126 | +// OR: |
| 127 | +// export type Todo = RowType<typeof todos>; |
| 128 | +export type ListRecord = Database['lists']; |
| 129 | +``` |
| 130 | + |
| 131 | +This defines the local SQLite database schema and PowerSync will hydrate the tables once the SDK connects to the PowerSync instance. |
| 132 | + |
| 133 | +#### `BackendConnector` |
| 134 | + |
| 135 | +Create a new file called `BackendConnector.ts` in the `powersync` directory and add the following to the file. |
| 136 | +```typescript lib/powersync/BackendConnector.ts |
| 137 | +import { AbstractPowerSyncDatabase, PowerSyncBackendConnector, UpdateType } from '@powersync/web'; |
| 138 | + |
| 139 | +export class BackendConnector implements PowerSyncBackendConnector { |
| 140 | + private powersyncUrl: string | undefined; |
| 141 | + private powersyncToken: string | undefined; |
| 142 | + |
| 143 | + constructor() { |
| 144 | + this.powersyncUrl = process.env.NEXT_PUBLIC_POWERSYNC_URL; |
| 145 | + // This token is for development only. |
| 146 | + // For production applications, integrate with an auth provider or custom auth. |
| 147 | + this.powersyncToken = process.env.NEXT_PUBLIC_POWERSYNC_TOKEN; |
| 148 | + } |
| 149 | + |
| 150 | + async fetchCredentials() { |
| 151 | + // TODO: Use an authentication service or custom implementation here. |
| 152 | + if (this.powersyncToken == null || this.powersyncUrl == null) { |
| 153 | + return null; |
| 154 | + } |
| 155 | + |
| 156 | + return { |
| 157 | + endpoint: this.powersyncUrl, |
| 158 | + token: this.powersyncToken |
| 159 | + }; |
| 160 | + } |
| 161 | + |
| 162 | + async uploadData(database: AbstractPowerSyncDatabase): Promise<void> { |
| 163 | + const transaction = await database.getNextCrudTransaction(); |
| 164 | + |
| 165 | + if (!transaction) { |
| 166 | + return; |
| 167 | + } |
| 168 | + |
| 169 | + try { |
| 170 | + for (const op of transaction.crud) { |
| 171 | + // The data that needs to be changed in the remote db |
| 172 | + const record = { ...op.opData, id: op.id }; |
| 173 | + switch (op.op) { |
| 174 | + case UpdateType.PUT: |
| 175 | + // TODO: Instruct your backend API to CREATE a record |
| 176 | + break; |
| 177 | + case UpdateType.PATCH: |
| 178 | + // TODO: Instruct your backend API to PATCH a record |
| 179 | + break; |
| 180 | + case UpdateType.DELETE: |
| 181 | + //TODO: Instruct your backend API to DELETE a record |
| 182 | + break; |
| 183 | + } |
| 184 | + } |
| 185 | + await transaction.complete(); |
| 186 | + } catch (error: any) { |
| 187 | + console.error(`Data upload error - discarding`, error); |
| 188 | + await transaction.complete(); |
| 189 | + } |
| 190 | + } |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +There are two core functions to this file: |
| 195 | +* `fetchCredentials()` - Used to return a JWT token to the PowerSync service for authentication. |
| 196 | +* `uploadData()` - Used to upload changes captured in the local SQLite database that need to be sent to the source backend database, in this case Supabase. We'll get back to this further down. |
| 197 | + |
| 198 | +You'll notice that we need to add a `.env` file to our project which will contain two variables: |
| 199 | +* `NEXT_PUBLIC_POWERSYNC_URL` - This is the PowerSync instance url. You can grab this from the PowerSync Cloud dashboard. |
| 200 | +* `NEXT_PUBLIC_POWERSYNC_TOKEN` - For development purposes we'll be using a development token. To generate one, please follow the steps outlined in [Development Token](/installation/authentication-setup/development-tokens) from our installation docs. |
| 201 | + |
| 202 | +### Create Providers |
| 203 | + |
| 204 | +Create a new directory in `./src/app/components` named `providers` |
| 205 | + |
| 206 | +#### `SystemProvider` |
| 207 | +Add a new file in the newly created `providers` directory called `SystemProvider.tsx`. |
| 208 | + |
| 209 | +```typescript components/providers/SystemProvider.tsx |
| 210 | +'use client'; |
| 211 | + |
| 212 | +import { AppSchema } from '@/lib/powersync/AppSchema'; |
| 213 | +import { BackendConnector } from '@/lib/powersync/BackendConnector'; |
| 214 | +import { PowerSyncContext } from '@powersync/react'; |
| 215 | +import { PowerSyncDatabase, WASQLiteOpenFactory, WASQLiteVFS } from '@powersync/web'; |
| 216 | +import Logger from 'js-logger'; |
| 217 | +import React, { Suspense } from 'react'; |
| 218 | + |
| 219 | +// eslint-disable-next-line react-hooks/rules-of-hooks |
| 220 | +Logger.useDefaults(); |
| 221 | +Logger.setLevel(Logger.DEBUG); |
| 222 | + |
| 223 | +export const db = new PowerSyncDatabase({ |
| 224 | + schema: AppSchema, |
| 225 | + database: new WASQLiteOpenFactory({ |
| 226 | + dbFilename: 'exampleVFS.db', |
| 227 | + vfs: WASQLiteVFS.OPFSCoopSyncVFS, |
| 228 | + flags: { |
| 229 | + enableMultiTabs: typeof SharedWorker !== 'undefined', |
| 230 | + ssrMode: false |
| 231 | + } |
| 232 | + }), |
| 233 | + flags: { |
| 234 | + enableMultiTabs: typeof SharedWorker !== 'undefined', |
| 235 | + } |
| 236 | +}); |
| 237 | + |
| 238 | +const connector = new BackendConnector(); |
| 239 | +db.connect(connector); |
| 240 | + |
| 241 | +export const SystemProvider = ({ children }: { children: React.ReactNode }) => { |
| 242 | + |
| 243 | + return ( |
| 244 | + <Suspense> |
| 245 | + <PowerSyncContext.Provider value={db}>{children}</PowerSyncContext.Provider> |
| 246 | + </Suspense> |
| 247 | + ); |
| 248 | +}; |
| 249 | + |
| 250 | +export default SystemProvider; |
| 251 | + |
| 252 | +``` |
| 253 | + |
| 254 | +The `SystemProvider` will be responsible for initializing the `PowerSyncDatabase`. Here we supply a few arguments, such as the AppSchema we defined earlier along with very important properties such as `ssrMode: false`. |
| 255 | +PowerSync will not work when rendered server side, so we need to explicitly disable SSR. |
| 256 | + |
| 257 | +We also instantiate our `BackendConnector` and pass an instance of that to `db.connect()`. This will connect to the PowerSync instance, validate the token supplied in the `fetchCredentials` function and then start syncing with the PowerSync service. |
| 258 | + |
| 259 | +#### DynamicSystemProvider.tsx |
| 260 | + |
| 261 | +Add a new file in the newly created `providers` directory called `DynamicSystemProvider.tsx`. |
| 262 | + |
| 263 | +```typescript components/providers/DynamicSystemProvider.tsx |
| 264 | +'use client'; |
| 265 | + |
| 266 | +import dynamic from 'next/dynamic'; |
| 267 | + |
| 268 | +export const DynamicSystemProvider = dynamic(() => import('./SystemProvider'), { |
| 269 | + ssr: false |
| 270 | +}); |
| 271 | + |
| 272 | +``` |
| 273 | +We can only use PowerSync in client side rendering, so here we're setting `ssr:false` |
| 274 | + |
| 275 | +#### Update `layout.tsx` |
| 276 | + |
| 277 | +In our main `layout.tsx` we'll update the `RootLayout` function to use the `DynamicSystemProvider` created in the last step. |
| 278 | + |
| 279 | +```typescript app/layout.tsx |
| 280 | +import { Geist, Geist_Mono } from "next/font/google"; |
| 281 | +import "./globals.css"; |
| 282 | +import { DynamicSystemProvider } from '@/app/components/providers/DynamicSystemProvider'; |
| 283 | + |
| 284 | +const geistSans = Geist({ |
| 285 | + variable: "--font-geist-sans", |
| 286 | + subsets: ["latin"], |
| 287 | +}); |
| 288 | + |
| 289 | +const geistMono = Geist_Mono({ |
| 290 | + variable: "--font-geist-mono", |
| 291 | + subsets: ["latin"], |
| 292 | +}); |
| 293 | + |
| 294 | +export default function RootLayout({ |
| 295 | + children, |
| 296 | +}: Readonly<{ |
| 297 | + children: React.ReactNode; |
| 298 | +}>) { |
| 299 | + return ( |
| 300 | + <html lang="en"> |
| 301 | + <body |
| 302 | + className={`${geistSans.variable} ${geistMono.variable} antialiased`}> |
| 303 | + <DynamicSystemProvider>{children}</DynamicSystemProvider> |
| 304 | + </body> |
| 305 | + </html> |
| 306 | + ); |
| 307 | +} |
| 308 | + |
| 309 | +``` |
| 310 | + |
| 311 | +#### Use PowerSync |
| 312 | + |
| 313 | +##### Reading Data |
| 314 | +In our `page.tsx` we can now use the `useQuery` hook or other PowerSync functions to read data from the SQLite database and render the results in our application. |
| 315 | + |
| 316 | +```typescript app/page.tsx |
| 317 | +'use client'; |
| 318 | + |
| 319 | +import { useState, useEffect } from 'react'; |
| 320 | +import { useQuery, useStatus, usePowerSync } from '@powersync/react'; |
| 321 | + |
| 322 | +export default function Page() { |
| 323 | + // Hook |
| 324 | + const powersync = usePowerSync(); |
| 325 | + |
| 326 | + // Get database status information e.g. downloading, uploading and lastSycned dates |
| 327 | + const status = useStatus(); |
| 328 | + |
| 329 | + // Example 1: Reactive Query |
| 330 | + const { data: lists } = useQuery("SELECT * FROM lists"); |
| 331 | + |
| 332 | + // Example 2: Standard query |
| 333 | + const [lists, setLists] = useState([]); |
| 334 | + useEffect(() => { |
| 335 | + powersync.getAll('SELECT * from lists').then(setLists) |
| 336 | + }, []); |
| 337 | + |
| 338 | + return ( |
| 339 | + <ul> |
| 340 | + {lists.map(list => <li key={list.id}>{list.name}</li>)} |
| 341 | + </ul> |
| 342 | + ) |
| 343 | +} |
| 344 | +``` |
| 345 | + |
| 346 | +##### Writing Data |
| 347 | +Using the `execute` function we can also write data into our local SQLite database. |
| 348 | +```typescript |
| 349 | +await powersync.execute("INSERT INTO lists (id, created_at, name, owner_id) VALUES (?, ?, ?, ?)", [uuid(), new Date(), "Test", user_id]); |
| 350 | +``` |
| 351 | + |
| 352 | +Changes made against the local data will be stored in the upload queue and will be processed by the `uploadData` in the BackendConnector class. |
0 commit comments