← VolcanoCONTENT HISTORY
WHAT CHANGED
Update to Volcano
Snapshot Oct 24, 2026 · 13:36 UTC · version 0.2.26
Collection source: downloaded plugin package.
First saved snapshot
No earlier snapshot is available to establish a change.
Save Volcano and check future changes in Following.
Up to 10 plugins free · no card required.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"description": "Use for Next.js on Volcano and for web apps, pages, dashboards, boards, galleries, full-stack UIs, public HTTP routes, redirects, webhook ingress, middleware, API routes, server actions, and client or server SDK separation.",
"included_files": [],
"name": "volcano-nextjs",
"skill_md_contents": "---\nname: volcano-nextjs\ndescription: Use for Next.js on Volcano and for web apps, pages, dashboards, boards, galleries, full-stack UIs, public HTTP routes, redirects, webhook ingress, middleware, API routes, server actions, and client or server SDK separation.\n---\n# Volcano Next.js Skill\n\n## Role\nImplement Volcano SDK in Next.js with strict client/server separation and middleware-first auth checks. This skill is self-contained: env contract, shared client pattern, AuthContext template, middleware helpers, app/pages router examples, server actions, OAuth callback, and SSR safety are embedded.\n\n## Workflow\n1. Centralize the Volcano client behind a `getVolcano()` helper used by all client components.\n2. Use middleware helpers (`createServerClient`, `withAuth`, `getTokenFromRequest`) for server/middleware auth checks.\n3. Keep browser-only auth actions (OAuth initiation, localStorage session) out of server-only code.\n4. Validate redirect/protection behavior and hydration edge cases.\n\n## UI/UX Pairing\nRead `volcano-uiux` for every user-facing Next.js interface. It owns accessibility, responsive layout, loading and progress feedback, forms, navigation, and interface-state guidance. This skill owns the Next.js implementation and client/server boundary.\n\n## Environment Contract\n`.env.local`:\n```env\nNEXT_PUBLIC_VOLCANO_API_URL=https://api.yourproject.volcano.dev\nNEXT_PUBLIC_VOLCANO_ANON_KEY=ak-your-anon-key\nNEXT_PUBLIC_VOLCANO_DATABASE_NAME=your-database\n```\n- `NEXT_PUBLIC_*` vars are exposed to both client and server.\n- Server-only secrets MUST NOT have the `NEXT_PUBLIC_` prefix.\n\n## Execution Environments\n| Environment | SDK usage |\n|---|---|\n| Client Components | Full SDK — auth, queries, storage, realtime |\n| Server Components | No direct SDK — call API routes, server actions, or middleware-derived data |\n| Middleware | `@volcano.dev/sdk/next/middleware` helpers |\n| API Routes / Route Handlers | Server client for token validation; full SDK with the bearer token for queries |\n\n## Shared Client Pattern (lib/volcano.ts)\n```ts\n// lib/volcano.ts\nimport { VolcanoAuth } from '@volcano.dev/sdk';\n\nlet volcano: VolcanoAuth | null = null;\n\nexport function getVolcano(): VolcanoAuth {\n if (!volcano) {\n volcano = new VolcanoAuth({\n apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n });\n volcano.database(process.env.NEXT_PUBLIC_VOLCANO_DATABASE_NAME!);\n }\n return volcano;\n}\n```\n\n## AuthContext Provider (Client Component)\n```tsx\n// context/AuthContext.tsx\n'use client';\n\nimport { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';\nimport { getVolcano } from '@/lib/volcano';\nimport type { User } from '@volcano.dev/sdk';\n\ninterface AuthContextType {\n user: User | null;\n loading: boolean;\n signIn: (email: string, password: string) => Promise<void>;\n signUp: (email: string, password: string) => Promise<void>;\n signOut: () => Promise<void>;\n}\n\nconst AuthContext = createContext<AuthContextType | null>(null);\n\nexport function AuthProvider({ children }: { children: ReactNode }) {\n const [user, setUser] = useState<User | null>(null);\n const [loading, setLoading] = useState(true);\n\n useEffect(() => {\n const volcano = getVolcano();\n\n // Restore session on mount\n volcano.initialize().then(({ user }) => {\n setUser(user);\n setLoading(false);\n });\n\n // React to subsequent changes\n const unsubscribe = volcano.auth.onAuthStateChange(setUser);\n return () => unsubscribe();\n }, []);\n\n const signIn = useCallback(async (email: string, password: string) => {\n const { error } = await getVolcano().auth.signIn({ email, password });\n if (error) throw error;\n }, []);\n\n const signUp = useCallback(async (email: string, password: string) => {\n const { error } = await getVolcano().auth.signUp({ email, password });\n if (error) throw error;\n }, []);\n\n const signOut = useCallback(async () => {\n await getVolcano().auth.signOut();\n }, []);\n\n return (\n <AuthContext.Provider value={{ user, loading, signIn, signUp, signOut }}>\n {children}\n </AuthContext.Provider>\n );\n}\n\nexport function useAuth() {\n const ctx = useContext(AuthContext);\n if (!ctx) throw new Error('useAuth must be used within an AuthProvider');\n return ctx;\n}\n```\n\nWire in `app/layout.tsx`:\n```tsx\nimport { AuthProvider } from '@/context/AuthContext';\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\"><body><AuthProvider>{children}</AuthProvider></body></html>\n );\n}\n```\n\n## Protected Page (Client Component)\n```tsx\n'use client';\nimport { useAuth } from '@/context/AuthContext';\nimport { useRouter } from 'next/navigation';\nimport { useEffect, useState } from 'react';\nimport { getVolcano } from '@/lib/volcano';\n\nexport default function DashboardPage() {\n const { user, loading } = useAuth();\n const router = useRouter();\n const [posts, setPosts] = useState<any[]>([]);\n\n useEffect(() => {\n if (!loading && !user) router.push('/login');\n }, [user, loading, router]);\n\n useEffect(() => {\n if (!user) return;\n getVolcano()\n .from('posts')\n .select('*')\n .order('created_at', { ascending: false })\n .limit(10)\n .then(({ data }) => setPosts(data ?? []));\n }, [user]);\n\n if (loading) return <div>Loading...</div>;\n if (!user) return null;\n\n return (\n <div>\n <h1>Welcome, {user.email}</h1>\n <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>\n </div>\n );\n}\n```\n\n## Middleware (Edge auth checks)\n```ts\n// middleware.ts\nimport { NextResponse } from 'next/server';\nimport type { NextRequest } from 'next/server';\nimport { createServerClient, withAuth } from '@volcano.dev/sdk/next/middleware';\n\nexport async function middleware(request: NextRequest) {\n const client = createServerClient({\n anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL,\n });\n\n const user = await withAuth(request, client);\n\n if (request.nextUrl.pathname.startsWith('/dashboard') && !user) {\n return NextResponse.redirect(new URL('/login', request.url));\n }\n if (request.nextUrl.pathname === '/login' && user) {\n return NextResponse.redirect(new URL('/dashboard', request.url));\n }\n return NextResponse.next();\n}\n\nexport const config = {\n matcher: ['/dashboard/:path*', '/login'],\n};\n```\n\n## Middleware Helpers API\n```ts\nimport {\n createServerClient,\n withAuth,\n getTokenFromRequest,\n isBrowser,\n isServer,\n} from '@volcano.dev/sdk/next/middleware';\n\nconst client = createServerClient({ anonKey, apiUrl }); // apiUrl optional\n\nconst user = await withAuth(request, client); // null when invalid/missing\nconst token = getTokenFromRequest(request); // Authorization header or cookie\nconst { user, error } = await client.getUser(token);\nconst { accessToken, refreshToken, error } = await client.refreshToken(oldRefreshToken);\n\nif (isBrowser()) { /* ... */ }\nif (isServer()) { /* ... */ }\n```\n\n## API Routes — App Router (Route Handlers)\n```ts\n// app/api/posts/route.ts\nimport { NextRequest, NextResponse } from 'next/server';\nimport { createServerClient, getTokenFromRequest } from '@volcano.dev/sdk/next/middleware';\nimport { VolcanoAuth } from '@volcano.dev/sdk';\n\nexport async function GET(request: NextRequest) {\n const client = createServerClient({\n anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL,\n });\n\n const token = getTokenFromRequest(request);\n if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });\n\n const { user, error } = await client.getUser(token);\n if (error || !user) return NextResponse.json({ error: 'Invalid token' }, { status: 401 });\n\n // Use the SDK with the bearer token for queries / function calls\n const volcano = new VolcanoAuth({\n apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n accessToken: token,\n });\n\n const { data, error: queryError } = await volcano.functions.invoke('get-user-posts');\n if (queryError) return NextResponse.json({ error: queryError.message }, { status: 500 });\n\n return NextResponse.json({ posts: data });\n}\n```\n\n## API Routes — Pages Router\n```ts\n// pages/api/posts.ts\nimport type { NextApiRequest, NextApiResponse } from 'next';\nimport { VolcanoAuth } from '@volcano.dev/sdk';\n\nexport default async function handler(req: NextApiRequest, res: NextApiResponse) {\n const token = req.headers.authorization?.replace('Bearer ', '');\n if (!token) return res.status(401).json({ error: 'Unauthorized' });\n\n const volcano = new VolcanoAuth({\n apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n accessToken: token,\n });\n volcano.database(process.env.NEXT_PUBLIC_VOLCANO_DATABASE_NAME!);\n\n const { data, error } = await volcano\n .from('posts')\n .select('*')\n .order('created_at', { ascending: false });\n if (error) return res.status(500).json({ error: error.message });\n\n return res.status(200).json({ posts: data });\n}\n```\n\n## Cookie Sync — required for Server Actions and middleware token reads\n\nThe SDK stores tokens in localStorage by default. Server Actions, route handlers, and middleware run on the server and cannot read localStorage — they read cookies. Add a small client component that mirrors the access token to a cookie whenever auth state changes:\n\n```tsx\n// components/CookieSync.tsx\n'use client';\nimport { useEffect } from 'react';\nimport { getVolcano } from '@/lib/volcano';\n\nexport function CookieSync() {\n useEffect(() => {\n const volcano = getVolcano();\n const sync = (token: string | null) => {\n const base = 'Path=/; SameSite=Lax; Secure';\n document.cookie = token\n ? `volcano_access_token=${token}; ${base}; Max-Age=3600`\n : `volcano_access_token=; ${base}; Max-Age=0`;\n };\n sync(volcano.accessToken ?? null);\n const unsub = volcano.auth.onAuthStateChange(() => {\n sync(volcano.accessToken ?? null);\n });\n return () => unsub();\n }, []);\n return null;\n}\n```\n\nMount it once, alongside the `AuthProvider`:\n\n```tsx\n// app/layout.tsx\nimport { AuthProvider } from '@/context/AuthContext';\nimport { CookieSync } from '@/components/CookieSync';\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang=\"en\">\n <body>\n <AuthProvider>\n <CookieSync />\n {children}\n </AuthProvider>\n </body>\n </html>\n );\n}\n```\n\n**Security note:** This cookie is JS-readable, which matches the SDK's localStorage exposure — the XSS surface area is identical. For higher-security postures, set an `httpOnly` cookie from an API route after sign-in and read it server-side via `getTokenFromRequest`. The client-mirrored pattern shown above is the simplest route to a working Server Action.\n\n## Server Components & Server Actions\nServer Components don't have localStorage. Three options:\n1. Fetch from API routes (which validate the token).\n2. Use Server Actions with the SDK and the cookie token.\n3. Pass middleware-derived data via headers.\n\n### Server Action template\n```ts\n// app/actions.ts\n'use server';\n\nimport { cookies } from 'next/headers';\nimport { createServerClient } from '@volcano.dev/sdk/next/middleware';\nimport { VolcanoAuth } from '@volcano.dev/sdk';\n\nexport async function getPosts() {\n const cookieStore = cookies();\n const token = cookieStore.get('volcano_access_token')?.value;\n if (!token) throw new Error('Not authenticated');\n\n const client = createServerClient({\n anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL,\n });\n const { user, error } = await client.getUser(token);\n if (error || !user) throw new Error('Invalid session');\n\n const volcano = new VolcanoAuth({\n apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n accessToken: token,\n });\n volcano.database(process.env.NEXT_PUBLIC_VOLCANO_DATABASE_NAME!);\n\n const { data } = await volcano\n .from('posts')\n .select('id, title, created_at')\n .order('created_at', { ascending: false })\n .limit(10);\n return data;\n}\n```\n\n```tsx\n// app/posts/page.tsx\nimport { getPosts } from '@/app/actions';\n\nexport default async function PostsPage() {\n const posts = await getPosts();\n return <ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;\n}\n```\n\n## Realtime in Next.js (Client Component required)\n```tsx\n'use client';\nimport { useEffect, useState } from 'react';\nimport { VolcanoRealtime } from '@volcano.dev/sdk/realtime';\nimport { getVolcano } from '@/lib/volcano';\nimport { useAuth } from '@/context/AuthContext';\n\nexport function LivePosts() {\n const { user } = useAuth();\n const [posts, setPosts] = useState<any[]>([]);\n\n useEffect(() => {\n if (!user) return;\n const volcano = getVolcano();\n\n volcano\n .from('posts')\n .select('*')\n .order('created_at', { ascending: false })\n .then(({ data }) => setPosts(data ?? []));\n\n const realtime = new VolcanoRealtime({\n apiUrl: process.env.NEXT_PUBLIC_VOLCANO_API_URL!,\n anonKey: process.env.NEXT_PUBLIC_VOLCANO_ANON_KEY!,\n accessToken: volcano.accessToken!,\n });\n\n realtime.connect().then(() => {\n const channel = realtime.channel('posts', { type: 'postgres' });\n channel.onPostgresChanges('INSERT', 'public', 'posts', (c) =>\n setPosts((cur) => [c.record, ...cur]));\n channel.onPostgresChanges('UPDATE', 'public', 'posts', (c) =>\n setPosts((cur) => cur.map((p) => (p.id === c.record.id ? c.record : p))));\n channel.onPostgresChanges('DELETE', 'public', 'posts', (c) =>\n setPosts((cur) => cur.filter((p) => p.id !== c.old_record.id)));\n channel.subscribe();\n });\n\n return () => { realtime.disconnect(); };\n }, [user]);\n\n return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;\n}\n```\n\n## Default Signup & Login Pages\nWhen the prompt doesn't specify signup/login page design, apply the `volcano-auth` \"Default Signup & Login Page UX\" and the shared `volcano-uiux` rules — including the default signup-success alert — on top of the `AuthContext` from this skill. The examples below show Next.js auth wiring; add the labels, progress feedback, responsive layout, and complete states required by `volcano-uiux`.\n\n```tsx\n// app/signup/page.tsx — default signup page with a success alert\n'use client';\nimport { useState } from 'react';\nimport { useRouter } from 'next/navigation';\nimport Link from 'next/link';\nimport { useAuth } from '@/context/AuthContext';\n\nexport default function SignupPage() {\n const { signUp } = useAuth();\n const router = useRouter();\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [error, setError] = useState<string | null>(null);\n const [success, setSuccess] = useState(false);\n const [submitting, setSubmitting] = useState(false);\n\n const handleSubmit = async (e: React.FormEvent) => {\n e.preventDefault();\n if (submitting || success) return;\n setError(null);\n setSubmitting(true);\n try {\n await signUp(email, password);\n setSuccess(true); // default \"Signup success\" alert — shown even if not requested\n setSubmitting(false);\n setTimeout(() => router.push('/login'), 2000);\n } catch (err: any) {\n setError(err.message);\n setSubmitting(false);\n }\n };\n\n return (\n <div>\n {success && (\n <div role=\"alert\">Signup successful! Check your email to verify your account.</div>\n )}\n {error && <div role=\"alert\">{error}</div>}\n <form onSubmit={handleSubmit}>\n <label htmlFor=\"signup-email\">Email</label>\n <input id=\"signup-email\" type=\"email\" autoComplete=\"email\" value={email} onChange={(e) => setEmail(e.target.value)} required />\n <label htmlFor=\"signup-password\">Password</label>\n <input id=\"signup-password\" type=\"password\" autoComplete=\"new-password\" value={password} onChange={(e) => setPassword(e.target.value)} required />\n <button type=\"submit\" disabled={submitting || success} aria-busy={submitting}>\n {success ? 'Account created' : submitting ? 'Signing up…' : 'Sign Up'}\n </button>\n <span role=\"status\">{submitting ? 'Creating account…' : ''}</span>\n </form>\n <Link href=\"/login\">Already have an account? Log in</Link>\n </div>\n );\n}\n```\n\n```tsx\n// app/login/page.tsx — default login page (email/password)\n'use client';\nimport { useState } from 'react';\nimport { useRouter } from 'next/navigation';\nimport Link from 'next/link';\nimport { useAuth } from '@/context/AuthContext';\n\nexport default function LoginPage() {\n const { signIn } = useAuth();\n const router = useRouter();\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n const [error, setError] = useState<string | null>(null);\n const [submitting, setSubmitting] = useState(false);\n\n const handleSubmit = async (e: React.FormEvent) => {\n e.preventDefault();\n if (submitting) return;\n setError(null);\n setSubmitting(true);\n try {\n await signIn(email, password);\n router.push('/dashboard');\n } catch (err: any) {\n setError(err.message);\n setSubmitting(false);\n }\n };\n\n return (\n <div>\n {error && <div role=\"alert\">{error}</div>}\n <form onSubmit={handleSubmit}>\n <label htmlFor=\"login-email\">Email</label>\n <input id=\"login-email\" type=\"email\" autoComplete=\"email\" value={email} onChange={(e) => setEmail(e.target.value)} required />\n <label htmlFor=\"login-password\">Password</label>\n <input id=\"login-password\" type=\"password\" autoComplete=\"current-password\" value={password} onChange={(e) => setPassword(e.target.value)} required />\n <button type=\"submit\" disabled={submitting} aria-busy={submitting}>\n {submitting ? 'Signing in…' : 'Sign In'}\n </button>\n <span role=\"status\">{submitting ? 'Signing in…' : ''}</span>\n </form>\n <Link href=\"/signup\">Need an account? Sign up</Link>\n <Link href=\"/forgot-password\">Forgot password?</Link>\n </div>\n );\n}\n```\n\n## OAuth in Next.js\nOAuth initiation must be in a Client Component; the callback page reconciles the tokens. Add the OAuth button(s) to the default login page above (or use standalone, as shown) — don't replace the email/password form unless the user asks for OAuth-only login.\n\n```tsx\n// Add to app/login/page.tsx (or standalone) — OAuth button\n'use client';\nimport { getVolcano } from '@/lib/volcano';\n\nexport function GoogleSignInButton() {\n return (\n <button onClick={() => getVolcano().auth.signInWithGoogle()}>\n Sign in with Google\n </button>\n );\n}\n```\n\n```tsx\n// app/auth/callback/page.tsx\n'use client';\nimport { useEffect } from 'react';\nimport { useRouter } from 'next/navigation';\nimport { getVolcano } from '@/lib/volcano';\n\nexport default function AuthCallbackPage() {\n const router = useRouter();\n useEffect(() => {\n getVolcano().initialize().then(({ user }) => {\n router.push(user ? '/dashboard' : '/login?error=auth_failed');\n });\n }, [router]);\n return <div>Completing sign in...</div>;\n}\n```\n\n## SSR Safety\n- `volcano.auth.signInWith*` (OAuth) only works in browser; calling it server-side throws.\n- The SDK rejects service keys (`sk-...`) in browser environments.\n- Use `isBrowser()` / `isServer()` from middleware helpers when branching.\n\n## Best Practices\n- **Single client instance** via `getVolcano()`. Don't `new VolcanoAuth(...)` in components.\n- **Loading state** is a first-class case — don't render protected UI before `useAuth().loading === false`.\n- **Pair `connect()` with `disconnect()`** for realtime in `useEffect` cleanup.\n- **Validate tokens server-side** — do not trust client-supplied user state in API routes / actions.\n- **Use `NEXT_PUBLIC_` only for non-secrets**; service keys never get this prefix.\n\n## Verification Checklist\n- User-facing interfaces pass the `volcano-uiux` verification checklist.\n- Client/server responsibilities are clear and correct.\n- Middleware uses `withAuth` (or equivalent) before allowing protected routes.\n- Server components fetch via API routes / server actions / middleware data — never call browser SDK methods directly.\n- Imports, env vars, dependencies, and init order are consistent.\n- Realtime is wrapped in a Client Component with proper cleanup.\n\n## Optional Fallback References\n"
}SHA-256 of public snapshot: ac58dc74322c1d260540c4c66af6638995a9026c9693d10400ec6aea57800e4b