Skip to content
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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,7 @@ Do not import or use `router` inside `.loader()`, `.get()`, `.post()`, or `.rout

Using `router.href()` for links inside `.page()` and `.layout()` JSX is okay in simple app entries because their rendered JSX does not feed app route metadata the same way. If a loader-heavy app still hits a circular `typeof app` error, move the link UI into a component module until the router type is split from loader data.

Context `redirect()` intentionally accepts a plain `string`. Do not pass `router.href()` into redirects inside app-entry handlers. Redirect return values participate in handler return inference and can reintroduce the circular type path in loader-heavy apps.
Context `redirect()` intentionally accepts a plain `string`. Do not pass `router.href()` into redirects inside app-entry handlers (`.page()`, `.layout()`, etc.) — redirect return values participate in handler return inference and can reintroduce the circular type path in loader-heavy apps. Standalone `"use server"` action files (separate from the app entry) are safe to use `router.href()` since they do not feed return types back into `typeof app`.

</details>

Expand Down Expand Up @@ -1968,7 +1968,32 @@ function DeleteButton({ id }: { id: string }) {

### Redirecting After Actions

When a server action needs to navigate to a different page (e.g. after creating a resource), use the handler context `redirect` inside the action instead of `router.push()` on the client. Since every server action triggers a page re-render, calling `router.push()` after the action would briefly flash the re-rendered current page before navigating away.
When a server action needs to navigate to a different page (e.g. after creating a resource), use `redirect` inside the action instead of `router.push()` on the client. Since every server action triggers a page re-render, calling `router.push()` after the action would briefly flash the re-rendered current page before navigating away.

In standalone `"use server"` action files, always wrap the redirect target with `router.href()` for type safety — TypeScript will catch invalid paths and missing params at compile time:

```tsx
// src/actions.ts
'use server'

import { redirect } from 'spiceflow'
import { router } from 'spiceflow/react'
import { parseFormData } from 'spiceflow'
import type { z } from 'zod'
import { projectSchema } from './schemas.ts'

export async function createProject(formData: FormData) {
const { name } = parseFormData(projectSchema, formData)
const project = await db.projects.create({ name })
// router.href validates the path and params against the route table at compile time
throw redirect(router.href('/orgs/:orgId/projects/:projectId', {
orgId: project.orgId,
projectId: project.id,
}))
}
```

For inline actions defined directly inside a `.page()` or `.layout()` handler (in the same file as `export const app`), use the handler context `redirect` with a plain string or the `params` option instead. The `router.href()` type reads from `typeof app`, which can create a circular TypeScript error when used inside an app-entry handler:

```tsx
import { Spiceflow, parseFormData } from 'spiceflow'
Expand All @@ -1987,6 +2012,7 @@ export const app = new Spiceflow()
'use server'
const { name } = parseFormData(projectSchema, formData)
const project = await db.projects.create({ name, orgId: params.orgId })
// Use plain string redirect inside app-entry inline actions to avoid circular types
throw redirect('/orgs/:orgId/projects/:projectId', {
params: { orgId: params.orgId, projectId: project.id },
})
Expand Down
7 changes: 4 additions & 3 deletions example-better-auth/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// the bearer token from the request headers, then validates the session via
// better-auth. Actions that modify data check auth before proceeding.
import { getActionRequest, redirect } from 'spiceflow'
import { router } from 'spiceflow/react'
import { eq } from 'drizzle-orm'
import { auth, db } from './auth.js'
import * as schema from './schema.js'
Expand Down Expand Up @@ -32,7 +33,7 @@ export async function getCurrentUser() {

export async function requireAuthOrRedirect() {
const session = await getSession()
if (!session) throw redirect('/login')
if (!session) throw redirect(router.href('/login'))
return { userId: session.user.id }
}

Expand All @@ -46,7 +47,7 @@ export async function createOrg(name: string) {
ownerId: session.user.id,
createdAt: new Date(),
})
throw redirect(`/orgs/${id}/dashboard`)
throw redirect(router.href('/orgs/:orgId/dashboard', { orgId: id }))
}

export async function createProject(orgId: string, name: string) {
Expand Down Expand Up @@ -78,5 +79,5 @@ export async function deleteProject(orgId: string, projectId: string) {
})
if (!project) throw new Error('not found')
await db.delete(schema.project).where(eq(schema.project.id, projectId))
throw redirect(`/orgs/${orgId}/dashboard`)
throw redirect(router.href('/orgs/:orgId/dashboard', { orgId }))
}
3 changes: 2 additions & 1 deletion example-forms/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
'use server'

import { redirect } from 'spiceflow'
import { router } from 'spiceflow/react'
import type { z } from 'zod'
import type { contactSchema, projectSchema, feedbackSchema } from './schemas.ts'

Expand All @@ -15,7 +16,7 @@ export async function createContact(data: z.infer<typeof contactSchema>) {
export async function createProject(data: z.infer<typeof projectSchema>) {
console.log('Creating project:', data)
const id = crypto.randomUUID().slice(0, 8)
throw redirect(`/success?name=${encodeURIComponent(data.name)}&id=${id}`)
throw redirect(router.href('/success', { name: data.name, id }))
}

export async function submitFeedback(data: z.infer<typeof feedbackSchema>) {
Expand Down
3 changes: 3 additions & 0 deletions example-stripe/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
dist
*.sqlite
7 changes: 7 additions & 0 deletions example-stripe/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit'

export default defineConfig({
schema: './src/schema.ts',
out: './drizzle',
dialect: 'sqlite',
})
21 changes: 21 additions & 0 deletions example-stripe/drizzle/20260508144813_initial/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
CREATE TABLE `org` (
`org_id` text PRIMARY KEY,
`name` text NOT NULL,
`email` text NOT NULL,
`stripe_customer_id` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `subscription` (
`subscription_id` text NOT NULL,
`variant_id` text NOT NULL,
`product_id` text NOT NULL,
`customer_id` text,
`org_id` text NOT NULL,
`status` text NOT NULL,
`created_at` integer NOT NULL,
CONSTRAINT `subscription_pk` PRIMARY KEY(`subscription_id`, `variant_id`),
CONSTRAINT `fk_subscription_org_id_org_org_id_fk` FOREIGN KEY (`org_id`) REFERENCES `org`(`org_id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `subscription_org_id_idx` ON `subscription` (`org_id`);
187 changes: 187 additions & 0 deletions example-stripe/drizzle/20260508144813_initial/snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
{
"version": "7",
"dialect": "sqlite",
"id": "10333871-613a-4caa-be29-7d4fa11ba767",
"prevIds": [
"00000000-0000-0000-0000-000000000000"
],
"ddl": [
{
"name": "org",
"entityType": "tables"
},
{
"name": "subscription",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "org_id",
"entityType": "columns",
"table": "org"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "org"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "org"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "stripe_customer_id",
"entityType": "columns",
"table": "org"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "org"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "subscription_id",
"entityType": "columns",
"table": "subscription"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "variant_id",
"entityType": "columns",
"table": "subscription"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "product_id",
"entityType": "columns",
"table": "subscription"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "customer_id",
"entityType": "columns",
"table": "subscription"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "org_id",
"entityType": "columns",
"table": "subscription"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "subscription"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "subscription"
},
{
"columns": [
"org_id"
],
"tableTo": "org",
"columnsTo": [
"org_id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_subscription_org_id_org_org_id_fk",
"entityType": "fks",
"table": "subscription"
},
{
"columns": [
"subscription_id",
"variant_id"
],
"nameExplicit": false,
"name": "subscription_pk",
"entityType": "pks",
"table": "subscription"
},
{
"columns": [
"org_id"
],
"nameExplicit": false,
"name": "org_pk",
"table": "org",
"entityType": "pks"
},
{
"columns": [
{
"value": "org_id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "subscription_org_id_idx",
"entityType": "indexes",
"table": "subscription"
}
],
"renames": []
}
31 changes: 31 additions & 0 deletions example-stripe/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "example-stripe",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node dist/rsc/index.js",
"test": "vitest",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"drizzle-orm": "beta",
"react": "19.2.4",
"react-dom": "19.2.4",
"spiceflow": "workspace:^",
"stripe": "^18.1.0",
"typescript": "5.7.3",
"ulid": "^2.3.0",
"vite": "^8.0.8"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"drizzle-kit": "beta",
"emulate": "^0.5.0",
"vitest": "^4.1.5"
}
}
13 changes: 13 additions & 0 deletions example-stripe/src/apply-migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Vitest setup file: applies drizzle migrations to the in-memory SQLite
// database before tests run. DB_PATH is not set in test env so we use
// the default in-memory database created by db.ts.
import { drizzle } from 'drizzle-orm/node-sqlite'
import { migrate } from 'drizzle-orm/node-sqlite/migrator'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { database } from './db.js'

const migrationsDb = drizzle({ client: database })
migrate(migrationsDb, {
migrationsFolder: join(dirname(fileURLToPath(import.meta.url)), '../drizzle'),
})
9 changes: 9 additions & 0 deletions example-stripe/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Drizzle client for SQLite. Reads DB_PATH env var, defaults to
// 'stripe-example.sqlite'. Tests set DB_PATH=':memory:' via vitest env.
import { drizzle } from 'drizzle-orm/node-sqlite'
import { DatabaseSync } from 'node:sqlite'
import * as schema from './schema.js'

const dbPath = process.env.DB_PATH || 'stripe-example.sqlite'
export const database = new DatabaseSync(dbPath)
export const db = drizzle({ client: database, schema, relations: schema.relations })
Loading