diff --git a/README.md b/README.md
index d980029..ba135b6 100644
--- a/README.md
+++ b/README.md
@@ -126,7 +126,7 @@ In theory, any API route may have a corresponding action route.
 - [X] Content negotiation (custom request data)
 - [ ] `<form method="dialog">` (on hold, see https://github.com/whatwg/html/issues/9625)
 - [ ] Tests
-  - [ ] Form with redirects
+  - [X] Form with redirects
   - [ ] Form with files
 - [ ] Documentation
 - [ ] Remix support
diff --git a/packages/iceform-next-sandbox/.gitignore b/packages/iceform-next-sandbox/.gitignore
index 8f322f0..bc7c3e7 100644
--- a/packages/iceform-next-sandbox/.gitignore
+++ b/packages/iceform-next-sandbox/.gitignore
@@ -33,3 +33,4 @@ yarn-error.log*
 # typescript
 *.tsbuildinfo
 next-env.d.ts
+.db/
diff --git a/packages/iceform-next-sandbox/src/handlers/note.ts b/packages/iceform-next-sandbox/src/handlers/note.ts
index 93d9d66..55ed12f 100644
--- a/packages/iceform-next-sandbox/src/handlers/note.ts
+++ b/packages/iceform-next-sandbox/src/handlers/note.ts
@@ -214,6 +214,9 @@ const createNote = (params: NoteCollectionParams): NextApiHandler => async (req,
 	}
 
 	// how to genericize the location URL?
+	// TODO check if req.url only returns the path of this API
+	//   req.url returns '/a/notes' when accessed from /a/notes, so we need to associate each action
+	//   route to each API route
 	res.setHeader('Location', `${params.basePath}/${newId}`);
 
 	res.status(201).json({
diff --git a/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts b/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts
index 0d28175..9f7d994 100644
--- a/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts
+++ b/packages/iceform-next-sandbox/src/pages/a/notes/[noteId].ts
@@ -2,7 +2,7 @@ import {NextPage} from 'next';
 import * as Iceform from '@modal-sh/iceform-next';
 import {noteResource} from '@/handlers/note';
 
-const ActionNotesIndexPage: NextPage = () => null;
+const ActionNotesResourcePage: NextPage = () => null;
 
 const getServerSideProps = Iceform.action.getServerSideProps({
 	fn: noteResource,
@@ -10,5 +10,5 @@ const getServerSideProps = Iceform.action.getServerSideProps({
 
 export {
 	getServerSideProps,
-	ActionNotesIndexPage as default,
+	ActionNotesResourcePage as default,
 };
diff --git a/packages/iceform-next-sandbox/src/pages/a/notes/index.ts b/packages/iceform-next-sandbox/src/pages/a/notes/index.ts
index cfd6e2a..a20a35d 100644
--- a/packages/iceform-next-sandbox/src/pages/a/notes/index.ts
+++ b/packages/iceform-next-sandbox/src/pages/a/notes/index.ts
@@ -2,15 +2,25 @@ import {NextPage} from 'next';
 import * as Iceform from '@modal-sh/iceform-next';
 import {noteCollection} from '@/handlers/note';
 
-const ActionNotesIndexPage: NextPage = () => null;
+const ActionNotesCollectionPage: NextPage = () => null;
+
+// this serves as an ID to associate the action URL to the API URL
+const RESOURCE_BASE_PATH = '/api/notes';
 
 const getServerSideProps = Iceform.action.getServerSideProps({
 	fn: noteCollection({
-		basePath: '/api/notes'
+		basePath: RESOURCE_BASE_PATH
 	}),
+	mapLocationToRedirectDestination: (referer, url) => {
+		const resourceBaseUrl = `${RESOURCE_BASE_PATH}/`;
+		if (url.startsWith(resourceBaseUrl)) {
+			return `/notes/${url.slice(resourceBaseUrl.length)}`;
+		}
+		return referer;
+	},
 });
 
 export {
 	getServerSideProps,
-	ActionNotesIndexPage as default,
+	ActionNotesCollectionPage as default,
 };
diff --git a/packages/iceform-next-sandbox/src/pages/api/notes/index.ts b/packages/iceform-next-sandbox/src/pages/api/notes/index.ts
index 249cbac..45b7cc1 100644
--- a/packages/iceform-next-sandbox/src/pages/api/notes/index.ts
+++ b/packages/iceform-next-sandbox/src/pages/api/notes/index.ts
@@ -1,9 +1,12 @@
 import * as Iceform from '@modal-sh/iceform-next';
 import { noteCollection } from '@/handlers/note';
 
+// this serves as an ID to associate the action URL to the API URL
+const RESOURCE_BASE_PATH = '/api/notes';
+
 const handler = Iceform.action.wrapApiHandler({
 	fn: noteCollection({
-		basePath: '/api/notes'
+		basePath: RESOURCE_BASE_PATH,
 	}),
 });
 
diff --git a/packages/iceform-next-sandbox/src/pages/greet.tsx b/packages/iceform-next-sandbox/src/pages/greet.tsx
index 6e272af..00ee4f0 100644
--- a/packages/iceform-next-sandbox/src/pages/greet.tsx
+++ b/packages/iceform-next-sandbox/src/pages/greet.tsx
@@ -5,7 +5,9 @@ const GreetPage: Iceform.NextPage = ({
   req,
   res,
 }) => {
-  const {response, ...isoformProps} = Iceform.useResponse(res);
+  const {response, loading, ...isoformProps} = Iceform.useResponse({
+		res
+	});
 
   const [responseData, setResponseData] = React.useState<unknown>();
   React.useEffect(() => {
diff --git a/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx b/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx
index b6511b8..18efec1 100644
--- a/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx
+++ b/packages/iceform-next-sandbox/src/pages/notes/[noteId].tsx
@@ -1 +1,57 @@
-// TODO view note page
+import * as Iceform from '@modal-sh/iceform-next';
+import * as React from 'react';
+
+const NotesItemPage: Iceform.NextPage = ({
+	req,
+	res,
+}) => {
+	const body = (res.body ?? {}) as Record<string, unknown>;
+	const {response, loading, ...isoformProps} = Iceform.useResponse({
+		res
+	});
+
+	const [responseData, setResponseData] = React.useState<unknown>();
+	React.useEffect(() => {
+		// response.bodyUsed might be undefined, so we use a strict comparison
+		if (response?.bodyUsed === false) {
+			response?.json().then((responseData) => {
+				setResponseData(responseData);
+			});
+		}
+	}, [response]);
+
+	return (
+		<Iceform.Form
+			{...isoformProps}
+			method="post"
+			action={`/a/notes/${req.query.noteId}`}
+			clientAction={`/api/notes/${req.query.noteId}`}
+		>
+			<div>
+				<label>
+					<span>Title</span>
+					<input type="text" name="title" defaultValue={body.title as string} />
+				</label>
+			</div>
+			<div>
+				<label>
+					<span>Image</span>
+					<input type="file" name="image" />
+				</label>
+			</div>
+			<div>
+				<label>
+					<span>Content</span>
+					<textarea name="content" defaultValue={body.content as string} />
+				</label>
+			</div>
+			<div>
+				<button type="submit">Submit</button>
+			</div>
+		</Iceform.Form>
+	)
+};
+
+export const getServerSideProps = Iceform.destination.getServerSideProps();
+
+export default NotesItemPage;
diff --git a/packages/iceform-next-sandbox/src/pages/notes/index.tsx b/packages/iceform-next-sandbox/src/pages/notes/index.tsx
index fa51247..c0d8db7 100644
--- a/packages/iceform-next-sandbox/src/pages/notes/index.tsx
+++ b/packages/iceform-next-sandbox/src/pages/notes/index.tsx
@@ -1 +1,36 @@
-// TODO view all notes/add notes page
+import { NextPage } from 'next';
+import * as Iceform from '@modal-sh/iceform-next';
+
+const NotesPage: NextPage = () => {
+	return (
+		<Iceform.Form
+			method="post"
+			action="/a/notes"
+			clientAction="/api/notes"
+		>
+			<div>
+				<label>
+					<span>Title</span>
+					<input type="text" name="title" />
+				</label>
+			</div>
+			<div>
+				<label>
+					<span>Image</span>
+					<input type="file" name="image" />
+				</label>
+			</div>
+			<div>
+				<label>
+					<span>Content</span>
+					<textarea name="content" />
+				</label>
+			</div>
+			<div>
+				<button type="submit">Submit</button>
+			</div>
+		</Iceform.Form>
+	)
+};
+
+export default NotesPage;
diff --git a/packages/iceform-next/src/client/hooks/useResponse.ts b/packages/iceform-next/src/client/hooks/useResponse.ts
index eb1fd38..967b729 100644
--- a/packages/iceform-next/src/client/hooks/useResponse.ts
+++ b/packages/iceform-next/src/client/hooks/useResponse.ts
@@ -10,22 +10,27 @@ export const useResponse = (params: UseResponseParams) => {
 	const [response, setResponse] = React.useState<Response | undefined>(
 		res.body ? new Response(res.body as unknown as BodyInit) : undefined,
 	);
+	const [loading, setLoading] = React.useState(false);
 
 	const invalidate = React.useCallback(() => {
 		setResponse(undefined);
+		setLoading(true);
 	}, []);
 
 	const refresh = React.useCallback((newResponse: Response) => {
 		setResponse(newResponse);
+		setLoading(false);
 	}, []);
 
 	return React.useMemo(() => ({
 		response,
 		refresh,
 		invalidate,
+		loading,
 	}), [
 		response,
 		refresh,
 		invalidate,
+		loading,
 	]);
 };
diff --git a/packages/iceform-next/src/server/constants.ts b/packages/iceform-next/src/server/constants.ts
new file mode 100644
index 0000000..f033ead
--- /dev/null
+++ b/packages/iceform-next/src/server/constants.ts
@@ -0,0 +1,8 @@
+export const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH', 'DELETE'] as const;
+export const PREVENT_REDIRECT_FORM_KEY = '__iceform_prevent_redirect' as const;
+export const DEFAULT_METHOD = 'GET' as const;
+export const DEFAULT_ENCODING = 'utf-8' as const;
+export const ACTION_STATUS_CODE = 307 as const; // temporary redirect
+export const DEFAULT_RESPONSE_STATUS_CODE = 200 as const; // ok
+export const CONTENT_TYPE_HEADER_KEY = 'content-type' as const;
+export const LOCATION_HEADER_KEY = 'location' as const;
diff --git a/packages/iceform-next/src/server.ts b/packages/iceform-next/src/server/index.ts
similarity index 64%
rename from packages/iceform-next/src/server.ts
rename to packages/iceform-next/src/server/index.ts
index 7e7f374..ea6ff1c 100644
--- a/packages/iceform-next/src/server.ts
+++ b/packages/iceform-next/src/server/index.ts
@@ -2,47 +2,60 @@ import {
 	GetServerSideProps,
 	NextApiHandler,
 	NextApiRequest as DefaultNextApiRequest,
-	NextApiResponse as DefaultNextApiResponse,
 	PageConfig,
 } from 'next';
-import { deserialize, serialize } from 'seroval';
+import { deserialize } from 'seroval';
 import {
 	NextApiResponse,
 	NextApiRequest,
-} from './common/types';
+} from '../common/types';
 import {
 	ENCTYPE_APPLICATION_JSON,
 	ENCTYPE_APPLICATION_OCTET_STREAM,
-} from './common/enctypes';
-import { getBody } from './utils/request';
+} from '../common/enctypes';
+import { getBody } from '../utils/request';
 import {
 	CookieManager,
 	BODY_COOKIE_KEY,
 	STATUS_CODE_COOKIE_KEY,
 	STATUS_MESSAGE_COOKIE_KEY,
 	CONTENT_TYPE_COOKIE_KEY,
-} from './utils/cookies';
-import { deserializeBody, EncTypeDeserializerMap } from './utils/serialization';
+} from '../utils/cookies';
+import { deserializeBody, EncTypeDeserializerMap } from '../utils/serialization';
+import { IceformNextServerResponse } from './response';
+import {
+	DEFAULT_METHOD,
+	DEFAULT_ENCODING,
+	DEFAULT_RESPONSE_STATUS_CODE,
+	ACTION_STATUS_CODE,
+	METHODS_WITH_BODY,
+	PREVENT_REDIRECT_FORM_KEY,
+} from './constants';
 
 export namespace destination {
 	export const getServerSideProps = (
 		gspFn?: GetServerSideProps,
 	): GetServerSideProps => async (ctx) => {
 		const req: NextApiRequest = {
-			query: ctx.query,
+			query: {
+				...ctx.query,
+				...(ctx.params ?? {}),
+			},
 			body: null,
 		};
-		const { method = 'GET' } = ctx.req;
+		const { method = DEFAULT_METHOD } = ctx.req;
 
-		if (!['GET', 'HEAD'].includes(method.toUpperCase())) {
+		if (METHODS_WITH_BODY.includes(method.toUpperCase() as typeof METHODS_WITH_BODY[number])) {
 			const body = await getBody(ctx.req);
-			req.body = body.toString('utf-8');
+			req.body = body.toString(DEFAULT_ENCODING);
 		}
 
 		const cookieManager = new CookieManager(ctx);
 		// TODO how to properly remove cookies without leftovers?
 		if (cookieManager.hasCookie(STATUS_CODE_COOKIE_KEY)) {
-			ctx.res.statusCode = Number(cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || '200');
+			ctx.res.statusCode = Number(
+				cookieManager.getCookie(STATUS_CODE_COOKIE_KEY) || DEFAULT_RESPONSE_STATUS_CODE,
+			);
 			cookieManager.unsetCookie(STATUS_CODE_COOKIE_KEY);
 		}
 
@@ -103,6 +116,11 @@ export namespace action {
 	export interface ActionWrapperOptions {
 		fn: NextApiHandler,
 		deserializers?: EncTypeDeserializerMap,
+		/**
+		 * Maps the Location header from the handler response to an accessible URL.
+		 * @param url
+		 */
+		mapLocationToRedirectDestination?: (referer: string, url: string) => string,
 	}
 
 	export const wrapApiHandler = (
@@ -120,7 +138,7 @@ export namespace action {
 	export const getServerSideProps = (
 		options: ActionWrapperOptions,
 	): GetServerSideProps => async (ctx) => {
-		const { referer } = ctx.req.headers;
+		const { referer = '/' } = ctx.req.headers;
 
 		const mockReq = {
 			...ctx.req,
@@ -130,56 +148,39 @@ export namespace action {
 			}),
 		} as DefaultNextApiRequest;
 
-		let data: unknown = null;
-		let contentType: string | undefined;
-		const mockRes = {
-			// todo handle other nextapiresponse methods (e.g. setting headers, writeHead, etc.)
-			statusMessage: '',
-			statusCode: 200,
-			status(code: number) {
-				// should we mask error status code to Bad Gateway?
-				this.statusCode = code;
-				return mockRes;
-			},
-			send: (raw?: unknown) => {
-				// xtodo: how to transfer binary response in a more compact way?
-				// > we let seroval handle this for now
-				if (typeof raw === 'undefined' || raw === null) {
-					return;
-				}
-
-				if (raw instanceof Buffer) {
-					contentType = ENCTYPE_APPLICATION_OCTET_STREAM;
-				}
-
-				data = serialize(raw);
-			},
-			json: (raw: unknown) => {
-				contentType = ENCTYPE_APPLICATION_JSON;
-				data = serialize(raw);
-			},
-		} as DefaultNextApiResponse;
-
+		const mockRes = new IceformNextServerResponse(ctx.req);
 		await options.fn(mockReq, mockRes);
 
 		const cookieManager = new CookieManager(ctx);
 		cookieManager.setCookie(STATUS_CODE_COOKIE_KEY, mockRes.statusCode.toString());
 		cookieManager.setCookie(STATUS_MESSAGE_COOKIE_KEY, mockRes.statusMessage);
-		if (data) {
-			cookieManager.setCookie(BODY_COOKIE_KEY, data as string);
-			if (contentType) {
-				cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, contentType);
+		if (mockRes.data) {
+			cookieManager.setCookie(BODY_COOKIE_KEY, mockRes.data as string);
+			if (mockRes.contentType) {
+				cookieManager.setCookie(CONTENT_TYPE_COOKIE_KEY, mockRes.contentType);
 			}
 		}
+		const preventRedirect = (
+			typeof mockReq.body === 'object'
+			&& mockReq.body !== null
+			&& PREVENT_REDIRECT_FORM_KEY in mockReq.body
+		);
+		const redirectDestination = (
+			mockRes.location
+			&& typeof options.mapLocationToRedirectDestination === 'function'
+			&& !preventRedirect
+		)
+			? options.mapLocationToRedirectDestination(referer, mockRes.location)
+			: referer;
 
 		return {
 			redirect: {
-				destination: referer,
-				statusCode: 307,
+				destination: redirectDestination,
+				statusCode: ACTION_STATUS_CODE,
 			},
 			props: {
 				query: ctx.query,
-				body: data,
+				body: mockRes.data,
 			},
 		};
 	};
diff --git a/packages/iceform-next/src/server/response.ts b/packages/iceform-next/src/server/response.ts
new file mode 100644
index 0000000..9d9bc6f
--- /dev/null
+++ b/packages/iceform-next/src/server/response.ts
@@ -0,0 +1,93 @@
+import { ServerResponse } from 'http';
+import { NextApiResponse as DefaultNextApiResponse } from 'next/dist/shared/lib/utils';
+import { serialize } from 'seroval';
+import { ENCTYPE_APPLICATION_JSON, ENCTYPE_APPLICATION_OCTET_STREAM } from '../common/enctypes';
+import { ACTION_STATUS_CODE, CONTENT_TYPE_HEADER_KEY, LOCATION_HEADER_KEY } from './constants';
+
+class DummyServerResponse {}
+
+const EffectiveServerResponse = ServerResponse ?? DummyServerResponse;
+
+export class IceformNextServerResponse
+	extends EffectiveServerResponse
+	implements DefaultNextApiResponse {
+	data?: unknown;
+
+	contentType?: string;
+
+	location?: string;
+
+	private readonly revalidateResponse = Promise.resolve(undefined);
+
+	setHeader(name: string, value: number | string | readonly string[]): this {
+		super.setHeader(name, value);
+
+		if (name.toLowerCase() === CONTENT_TYPE_HEADER_KEY) {
+			this.contentType = value.toString();
+		}
+
+		if (name.toLowerCase() === LOCATION_HEADER_KEY) {
+			this.location = value.toString();
+		}
+
+		return this;
+	}
+
+	clearPreviewData(): DefaultNextApiResponse {
+		// unused
+		return this as unknown as DefaultNextApiResponse;
+	}
+
+	json(body: unknown): void {
+		this.contentType = ENCTYPE_APPLICATION_JSON;
+		this.data = serialize(body);
+	}
+
+	revalidate(): Promise<void> {
+		// unused
+		return this.revalidateResponse;
+	}
+
+	send(body: unknown): void {
+		// xtodo: how to transfer binary response in a more compact way?
+		// > we let seroval handle this for now
+		if (typeof body === 'undefined' || body === null) {
+			return;
+		}
+
+		if (body instanceof Buffer) {
+			this.contentType = ENCTYPE_APPLICATION_OCTET_STREAM;
+		}
+
+		this.data = serialize(body);
+	}
+
+	setDraftMode(): DefaultNextApiResponse {
+		// unused
+		return this as unknown as DefaultNextApiResponse;
+	}
+
+	setPreviewData(): DefaultNextApiResponse {
+		// unused
+		return this as unknown as DefaultNextApiResponse;
+	}
+
+	status(statusCode: number): DefaultNextApiResponse {
+		this.statusCode = statusCode;
+		return this as unknown as DefaultNextApiResponse;
+	}
+
+	strictContentLength = true;
+
+	redirect(...args: [string] | [number, string]): DefaultNextApiResponse {
+		const [arg1, arg2] = args;
+		if (typeof arg1 === 'number' && typeof arg2 === 'string') {
+			this.statusCode = arg1;
+			this.setHeader('Location', arg2);
+		} else if (typeof arg1 === 'string') {
+			this.statusCode = ACTION_STATUS_CODE;
+			this.setHeader('Location', arg1);
+		}
+		return this as unknown as DefaultNextApiResponse;
+	}
+}
diff --git a/packages/iceform-next/src/utils/request.ts b/packages/iceform-next/src/utils/request.ts
index e047b97..f147a80 100644
--- a/packages/iceform-next/src/utils/request.ts
+++ b/packages/iceform-next/src/utils/request.ts
@@ -38,9 +38,10 @@ export const parseMultipartFormData = async (
 		});
 
 		file.on('close', () => {
-			body[name] = new File([fileData.buffer], filename, {
-				type: mimetype,
-			});
+			const newFile = fileData.buffer as unknown as Record<string, unknown>;
+			newFile.name = filename;
+			newFile.type = mimetype;
+			body[name] = newFile;
 		});
 	});