Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next.js SSR example #1596

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy_examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:


- name: Deploy NextJs example
working-directory: examples/nextjs-example
working-directory: examples/nextjs-ssr-example
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a naming nit: there's no need to suffix with -example, that's communicated by the parent folder name, so I would suggest examples/nextjs-ssr.

run: |
pnpm sst deploy --stage ${{ env.DEPLOY_ENV }}
if [ -f ".sst/outputs.json" ]; then
Expand Down
1 change: 1 addition & 0 deletions examples/nextjs-ssr-example/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build/**
44 changes: 44 additions & 0 deletions examples/nextjs-ssr-example/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
`eslint:recommended`,
`plugin:@typescript-eslint/recommended`,
`plugin:prettier/recommended`,
`eslint-config-next`,
],
parserOptions: {
ecmaVersion: 2022,
requireConfigFile: false,
sourceType: `module`,
ecmaFeatures: {
jsx: true,
},
},
parser: `@typescript-eslint/parser`,
plugins: [`prettier`],
rules: {
quotes: [`error`, `backtick`],
"no-unused-vars": `off`,
"@typescript-eslint/no-unused-vars": [
`error`,
{
argsIgnorePattern: `^_`,
varsIgnorePattern: `^_`,
caughtErrorsIgnorePattern: `^_`,
},
],
"@next/next/no-img-element": `off`,

},
ignorePatterns: [
`**/node_modules/**`,
`**/dist/**`,
`tsup.config.ts`,
`vitest.config.ts`,
`.eslintrc.js`,
],
};
10 changes: 10 additions & 0 deletions examples/nextjs-ssr-example/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
dist
.env.local

# Turborepo
.turbo

# next.js
/.next/
/out/
next-env.d.ts
5 changes: 5 additions & 0 deletions examples/nextjs-ssr-example/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"trailingComma": "es5",
"semi": false,
"tabWidth": 2
}
22 changes: 22 additions & 0 deletions examples/nextjs-ssr-example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Next.js SSR example
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally, the README for an example should ideally communicate a bit about what it's trying to show you, i.e.: what is the example, why is it interesting, what can you achieve with the pattern it demonstrates, etc. Plus perhaps some signposts into key parts of the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will work on that when we decide to publish


## Setup

1. Make sure you've installed all dependencies for the monorepo and built packages

From the root directory:

- `pnpm i`
- `pnpm run -r build`

2. Start the docker containers

`pnpm run backend:up`

3. Start the dev server

`pnpm run dev`

4. When done, tear down the backend containers so you can run other examples

`pnpm run backend:down`
25 changes: 25 additions & 0 deletions examples/nextjs-ssr-example/app/App.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.App {
text-align: center;
}

.App-logo {
height: min(160px, 30vmin);
pointer-events: none;
margin-top: min(30px, 5vmin);
margin-bottom: min(30px, 5vmin);
}

.App-header {
background-color: #1c1e20;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: top;
justify-content: top;
font-size: calc(10px + 2vmin);
color: white;
}

.App-link {
color: #61dafb;
}
41 changes: 41 additions & 0 deletions examples/nextjs-ssr-example/app/Example.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
.controls {
margin-bottom: 1.5rem;
}

.button {
display: inline-block;
line-height: 1.3;
text-align: center;
text-decoration: none;
vertical-align: middle;
cursor: pointer;
user-select: none;
width: calc(15vw + 100px);
margin-right: 0.5rem !important;
margin-left: 0.5rem !important;
border-radius: 32px;
text-shadow: 2px 6px 20px rgba(0, 0, 0, 0.4);
box-shadow: rgba(0, 0, 0, 0.5) 1px 2px 8px 0px;
background: #1e2123;
border: 2px solid #229089;
color: #f9fdff;
font-size: 16px;
font-weight: 500;
padding: 10px 18px;
}

.item {
display: block;
line-height: 1.3;
text-align: center;
vertical-align: middle;
width: calc(30vw - 1.5rem + 200px);
margin-right: auto;
margin-left: auto;
border-radius: 32px;
border: 1.5px solid #bbb;
box-shadow: rgba(0, 0, 0, 0.3) 1px 2px 8px 0px;
color: #f9fdff;
font-size: 13px;
padding: 10px 18px;
}
120 changes: 120 additions & 0 deletions examples/nextjs-ssr-example/app/Home.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client"

import { v4 as uuidv4 } from "uuid"
import { useShape, getShapeStream } from "@electric-sql/react"
import "./Example.css"
import { matchStream } from "./match-stream"
import { Row, ShapeStreamOptions } from "@electric-sql/client"
import { useOptimistic } from "react"
import { getProxiedOptions } from "./utils"

const parser = {
timestamptz: (date: string) => new Date(date).getTime(),
}

type Item = { id: string; created_at: number }

const options: Partial<ShapeStreamOptions> = {
table: `items`,
parser,
}

async function createItem(newId: string) {
const itemsStream = getShapeStream<Row>(getProxiedOptions(options))

// Match the insert
const findUpdatePromise = matchStream({
stream: itemsStream,
operations: [`insert`],
matchFn: ({ message }) => message.value.id === newId,
})

// Generate new UUID and post to backend
const fetchPromise = fetch(`/api/items`, {
method: `POST`,
body: JSON.stringify({ uuid: newId }),
})

return await Promise.all([findUpdatePromise, fetchPromise])
}

async function clearItems() {
const itemsStream = getShapeStream<Row>(getProxiedOptions(options))

// Match the delete
const findUpdatePromise = matchStream({
stream: itemsStream,
operations: [`delete`],
// First delete will match
matchFn: () => true,
})
// Post to backend to delete everything
const fetchPromise = fetch(`/api/items`, {
method: `DELETE`,
})

return await Promise.all([findUpdatePromise, fetchPromise])
}

export default function Home() {
const { data: items } = useShape(getProxiedOptions(options)) as unknown as {
data: Item[]
}

const [optimisticItems, updateOptimisticItems] = useOptimistic<
Item[],
{ newId?: string; isClear?: boolean }
>(items, (state, { newId, isClear }) => {
if (isClear) {
return []
}

if (newId) {
// Merge data from shape & optimistic data from fetchers. This removes
// possible duplicates as there's a potential race condition where
// useShape updates from the stream slightly before the action has finished.
const itemsMap = new Map()
state
.concat([{ id: newId, created_at: new Date().getTime() }])
.forEach((item) => {
itemsMap.set(item.id, { ...itemsMap.get(item.id), ...item })
})
return Array.from(itemsMap.values())
}

return []
})

return (
<div>
<form
action={async (formData: FormData) => {
const intent = formData.get(`intent`)
const newId = formData.get(`new-id`) as string
if (intent === `add`) {
updateOptimisticItems({ newId })
await createItem(newId)
} else if (intent === `clear`) {
updateOptimisticItems({ isClear: true })
await clearItems()
}
}}
>
<input type="hidden" name="new-id" value={uuidv4()} />
<button type="submit" className="button" name="intent" value="add">
Add
</button>
<button type="submit" className="button" name="intent" value="clear">
Clear
</button>
</form>
{optimisticItems
.sort((a, b) => a.created_at - b.created_at)
.map((item: Item, index: number) => (
<p key={index} className="item">
<code>{item.id}</code>
</p>
))}
</div>
)
}
17 changes: 17 additions & 0 deletions examples/nextjs-ssr-example/app/api/items/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { db } from "../../db"
import { NextResponse } from "next/server"

export async function POST(request: Request) {
const body = await request.json()
const result = await db.query(
`INSERT INTO items (id)
VALUES ($1) RETURNING id;`,
[body.uuid]
)
return NextResponse.json({ id: result.rows[0].id })
}

export async function DELETE() {
await db.query(`DELETE FROM items;`)
return NextResponse.json(`ok`)
}
18 changes: 18 additions & 0 deletions examples/nextjs-ssr-example/app/client-shapes-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"use client"

import { cacheShapeState } from "@electric-sql/react"
import { getUrl, SerializedShape } from "./utils"

export default function ClientShapeProvider({
children,
serializedShapes,
}: {
children: React.JSX.Element
serializedShapes: SerializedShape[]
}) {
for (const { options, data } of serializedShapes) {
const newShapeOptions = { ...options, url: getUrl() }
cacheShapeState(newShapeOptions, data)
}
return children
}
10 changes: 10 additions & 0 deletions examples/nextjs-ssr-example/app/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import pgPkg from "pg"
const { Client } = pgPkg

const db = new Client({
connectionString: process.env.DATABASE_URL,
})

db.connect()

export { db }
26 changes: 26 additions & 0 deletions examples/nextjs-ssr-example/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import "./style.css"
import "./App.css"

export const metadata = {
title: `Next.js Forms Example`,
description: `Example application with forms and Postgres.`,
}

export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<div className="App">
<header className="App-header">
<img src="/logo.svg" className="App-logo" alt="logo" />
{children}
</header>
</div>
</body>
</html>
)
}
Loading
Loading