├── .eslintignore ├── .github ├── CODE_OF_CONDUCT.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── 1_bug_report.yml │ └── config.yml ├── pull_request_template.md └── workflows │ └── cicd.yml ├── .gitignore ├── .husky ├── commit-msg └── prepare-commit-msg ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apps └── playground │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── src │ ├── app │ │ ├── (examples) │ │ │ ├── async-schema │ │ │ │ ├── login-action.ts │ │ │ │ └── page.tsx │ │ │ ├── bind-arguments │ │ │ │ ├── onboard-action.ts │ │ │ │ └── page.tsx │ │ │ ├── direct │ │ │ │ ├── login-action.ts │ │ │ │ └── page.tsx │ │ │ ├── empty-response │ │ │ │ ├── empty-action.ts │ │ │ │ └── page.tsx │ │ │ ├── file-upload │ │ │ │ ├── file-upload-action.ts │ │ │ │ └── page.tsx │ │ │ ├── hook │ │ │ │ ├── deleteuser-action.ts │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── navigation │ │ │ │ ├── navigation-action.ts │ │ │ │ └── page.tsx │ │ │ ├── nested-schema │ │ │ │ ├── page.tsx │ │ │ │ └── shop-action.ts │ │ │ ├── no-arguments │ │ │ │ ├── noargs-action.ts │ │ │ │ └── page.tsx │ │ │ ├── optimistic-hook │ │ │ │ ├── addtodo-action.ts │ │ │ │ ├── addtodo-form.tsx │ │ │ │ └── page.tsx │ │ │ ├── react-hook-form │ │ │ │ ├── buyproduct-action.ts │ │ │ │ ├── page.tsx │ │ │ │ └── validation.ts │ │ │ ├── stateful-form │ │ │ │ ├── page.tsx │ │ │ │ └── stateful-form-action.ts │ │ │ ├── stateless-form │ │ │ │ ├── page.tsx │ │ │ │ └── stateless-form-action.ts │ │ │ └── with-context │ │ │ │ ├── edituser-action.ts │ │ │ │ └── page.tsx │ │ ├── _components │ │ │ ├── example-github-link.tsx │ │ │ ├── example-link.tsx │ │ │ ├── result-box.tsx │ │ │ ├── styled-button.tsx │ │ │ ├── styled-heading.tsx │ │ │ └── styled-input.tsx │ │ ├── github-logo.tsx │ │ ├── globals.css │ │ ├── layout.tsx │ │ └── page.tsx │ └── lib │ │ └── safe-action.ts │ ├── tailwind.config.js │ └── tsconfig.json ├── assets └── logo.png ├── commitlint.config.js ├── package.json ├── packages └── next-safe-action │ ├── .eslintrc.js │ ├── LICENSE │ ├── README.md │ ├── package.json │ ├── release.config.cjs │ ├── src │ ├── __tests__ │ │ ├── action-callbacks.test.ts │ │ ├── bind-args-validation-errors.test.ts │ │ ├── happy-path.test.ts │ │ ├── metadata.test.ts │ │ ├── middleware.test.ts │ │ ├── server-error.test.ts │ │ └── validation-errors.test.ts │ ├── action-builder.ts │ ├── hooks-utils.ts │ ├── hooks.ts │ ├── hooks.types.ts │ ├── index.ts │ ├── index.types.ts │ ├── middleware.ts │ ├── next │ │ └── errors │ │ │ ├── bailout-to-csr.ts │ │ │ ├── dynamic-usage.ts │ │ │ ├── http-access-fallback.ts │ │ │ ├── index.ts │ │ │ ├── postpone.ts │ │ │ ├── redirect.ts │ │ │ └── router.ts │ ├── safe-action-client.ts │ ├── standard-schema.ts │ ├── stateful-hooks.ts │ ├── utils.ts │ ├── utils.types.ts │ ├── validation-errors.ts │ └── validation-errors.types.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── turbo.json └── website ├── .gitignore ├── babel.config.js ├── docs ├── contributing.md ├── define-actions │ ├── _category_.json │ ├── action-result-object.md │ ├── action-utils.md │ ├── bind-arguments.md │ ├── create-the-client.md │ ├── extend-previous-schemas.md │ ├── instance-methods.md │ ├── middleware.md │ └── validation-errors.md ├── execute-actions │ ├── _category_.json │ ├── direct-execution.md │ └── hooks │ │ ├── _category_.json │ │ ├── hook-callbacks.md │ │ ├── useaction.md │ │ ├── useoptimisticaction.md │ │ └── usestateaction.md ├── getting-started.md ├── integrations │ ├── _category_.json │ └── react-hook-form.md ├── migrations │ ├── _category_.json │ ├── v3-to-v4.md │ ├── v4-to-v5.md │ ├── v5-to-v6.md │ ├── v6-to-v7.md │ └── v7-to-v8.md ├── recipes │ ├── _category_.json │ ├── form-actions.md │ ├── i18n.md │ ├── playground.md │ └── upload-files.md ├── troubleshooting.md └── types │ ├── _category_.json │ └── infer-types.md ├── docusaurus.config.js ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── postcss.config.js ├── sidebars.js ├── src ├── components │ └── landing │ │ ├── features.tsx │ │ ├── getting-started.tsx │ │ ├── github-button.tsx │ │ ├── hero-example.tsx │ │ ├── hero.tsx │ │ ├── index.tsx │ │ ├── install-box.tsx │ │ ├── playground.tsx │ │ ├── sponsors.tsx │ │ ├── testimonials.tsx │ │ └── tweet.tsx ├── css │ └── custom.css └── pages │ └── index.tsx ├── static ├── .nojekyll ├── google0917abe14cfb4fd2.html ├── img │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── logo-dark-mode.svg │ ├── logo-light-mode.svg │ ├── logo.png │ ├── powered-by-vercel.svg │ ├── social-card.png │ └── x │ │ ├── 1weiho.jpg │ │ ├── CasterKno.jpg │ │ ├── ErfanEbrahimnia.jpg │ │ ├── Kingsley_codes.jpg │ │ ├── Lermatroid.jpg │ │ ├── Rajdeep__ds.jpg │ │ ├── Xexr.jpg │ │ ├── muratsutunc.jpg │ │ ├── nikelsnik.jpg │ │ ├── pontusab.jpg │ │ ├── rclmenezes.jpg │ │ ├── yesdavidgray.jpg │ │ └── zaphodias.jpg └── vid │ ├── demo.mp4 │ └── metadata-v8.mp4 ├── tailwind.config.js ├── tsconfig.json └── vercel.json /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.js 2 | /website -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies within all project spaces, and it also applies when an individual is representing the project or its community in public spaces. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainer(s) using the following email address: info\[at\]next-safe-action\[dot\]dev or via [private message on X](https://twitter.com/TheEdoRan). All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 1.4, available at 44 | 45 | For answers to common questions about this code of conduct, see 46 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [TheEdoRan] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1_bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a report to help us fix bugs. 3 | title: "[BUG] " 4 | labels: ["bug"] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Are you using the latest version of this library? 9 | description: Please confirm that you are using the latest version of next-safe-action. 10 | options: 11 | - label: I verified that the issue exists in the latest next-safe-action release 12 | required: true 13 | - type: checkboxes 14 | attributes: 15 | label: Is there an existing issue for this? 16 | description: Please search to see if an issue already exists for the bug you encountered. 17 | options: 18 | - label: I have searched the existing issues and found nothing that matches 19 | required: true 20 | - type: textarea 21 | attributes: 22 | label: Describe the bug 23 | description: A clear and concise description of what the bug is. 24 | placeholder: I found out that the '...' functionality is not working. 25 | validations: 26 | required: true 27 | - type: textarea 28 | attributes: 29 | label: Reproduction steps 30 | description: Steps to reproduce the incorrect behavior. 31 | placeholder: | 32 | A step-by-step reproduction of the bug. For example: 33 | 1. Go to '...' 34 | 2. Click on '...' 35 | 3. Scroll down to '...' 36 | 4. See error 37 | validations: 38 | required: true 39 | - type: textarea 40 | attributes: 41 | label: Expected behavior 42 | description: A description of what you expected to happen instead. 43 | placeholder: I expected that this happened instead. 44 | validations: 45 | required: true 46 | - type: input 47 | attributes: 48 | label: Link to a minimal reproduction of the issue 49 | description: Link to a minimal example that reproduces the bug. Please provide a GitHub/CodeSandbox link with as little code as possible to reproduce the issue. Without a link, the issue will be closed as "not planned" until a valid URL is provided. 50 | placeholder: https://github.com/... 51 | validations: 52 | required: true 53 | - type: markdown 54 | attributes: 55 | value: Information about the environment you are using. 56 | - type: input 57 | attributes: 58 | label: Operating System 59 | placeholder: Windows 11, macOS, Ubuntu 22.04 60 | validations: 61 | required: true 62 | - type: input 63 | attributes: 64 | label: Library version 65 | placeholder: 6.0.0 66 | validations: 67 | required: true 68 | - type: input 69 | attributes: 70 | label: Next.js version 71 | placeholder: 14.x.x 72 | validations: 73 | required: true 74 | - type: input 75 | attributes: 76 | label: Node.js version 77 | placeholder: 20.x.x 78 | validations: 79 | required: true 80 | - type: textarea 81 | attributes: 82 | label: Additional context 83 | description: Add any other context about the problem here. 84 | validations: 85 | required: false 86 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Feature request 4 | url: https://github.com/TheEdoRan/next-safe-action/discussions/new?category=ideas 5 | about: Do you want to help us improve the library? Open a new discussion to suggest an idea. 6 | - name: Ask a question 7 | url: https://github.com/TheEdoRan/next-safe-action/discussions/new?category=q-a 8 | about: Do you want to ask something about the library? Open a new discussion to get help from the community. 9 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Proposed changes 2 | 3 | Put your proposed changes here. 4 | 5 | ## Related issue(s) or discussion(s) 6 | 7 | re # 8 | 9 | --- 10 | 11 | - [ ] I read the [contributing guidelines](https://github.com/TheEdoRan/next-safe-action/blob/next/CONTRIBUTING.md) and followed them before creating this pull request. 12 | -------------------------------------------------------------------------------- /.github/workflows/cicd.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | paths: 6 | - "packages/**" 7 | - "turbo.json" 8 | - "package.json" 9 | branches: 10 | - main 11 | - beta 12 | - next 13 | - experimental 14 | - 4.x 15 | - 7.x 16 | pull_request: 17 | paths: 18 | - "packages/**" 19 | 20 | jobs: 21 | CI: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | with: 26 | fetch-depth: 2 27 | - uses: pnpm/action-setup@v4 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: "22" 32 | cache: "pnpm" 33 | - run: pnpm install --frozen-lockfile 34 | - run: pnpm run lint:lib 35 | - run: pnpm run test:lib 36 | 37 | CD: 38 | if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/beta' || github.ref == 'refs/heads/next'|| github.ref == 'refs/heads/experimental' || github.ref == 'refs/heads/4.x' || github.ref == 'refs/heads/7.x' }} 39 | runs-on: ubuntu-latest 40 | needs: [CI] 41 | steps: 42 | - uses: actions/checkout@v4 43 | with: 44 | fetch-depth: 2 45 | - uses: pnpm/action-setup@v4 46 | - name: Setup Node.js 47 | uses: actions/setup-node@v4 48 | with: 49 | node-version: "22" 50 | cache: "pnpm" 51 | - run: pnpm install --frozen-lockfile 52 | - run: pnpm run build:lib 53 | - name: Release lib to NPM 54 | env: 55 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | run: pnpm run deploy:lib 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | .AppleDouble 4 | .LSOverride 5 | 6 | node_modules 7 | yarn.lock 8 | dist 9 | .env* 10 | *.pem 11 | /.npmrc 12 | 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Exclude .example files 18 | !*.example 19 | 20 | # ESLint 21 | .eslintcache 22 | 23 | # TypeScript stuff 24 | *.tsbuildinfo 25 | 26 | # Turborepo 27 | .turbo 28 | 29 | # Local test file 30 | /packages/next-safe-action/src/test.ts 31 | 32 | # Website 33 | /website/.docusaurus 34 | /website/node_modules -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | pnpm exec commitlint --edit "${1}" -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | exec < /dev/tty && pnpm exec cz --hook || true -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .turbo 4 | .next 5 | *.md 6 | *.mdx -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 120, 4 | "useTabs": true, 5 | "arrowParens": "always", 6 | "tabWidth": 2, 7 | "semi": true, 8 | "singleQuote": false, 9 | "quoteProps": "consistent", 10 | "plugins": ["prettier-plugin-tailwindcss"] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": ["typescript", "typescriptreact"], 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": "explicit", 5 | "source.fixAll.eslint": "explicit" 6 | }, 7 | "editor.rulers": [120], 8 | "[markdown]": { 9 | "editor.formatOnSave": false 10 | }, 11 | "typescript.tsdk": "node_modules/typescript/lib" 12 | } 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to next-safe-action 2 | 3 | Code contributions are very welcome, so if you decide to help improve the library code, thank you! First of all, though, please read the guidelines below. 4 | 5 | ## Information about the project 6 | 7 | This is a monorepo, that uses: 8 | 9 | - [pnpm](https://pnpm.io/) as package manager; 10 | - [Turborepo](https://turbo.build/repo) as build system; 11 | - [TypeScript](https://www.typescriptlang.org/) as primary language; 12 | - [ESLint](https://eslint.org/) as linter; 13 | - [Prettier](https://prettier.io/) as formatter; 14 | - [Husky](https://github.com/typicode/husky) as Git hooks manager; 15 | - [Commitizen](https://github.com/commitizen/cz-cli) as commit message manager; 16 | - [Commitlint](https://commitlint.js.org/) as commit message linter; 17 | - [semantic-release](https://github.com/semantic-release/semantic-release) as release manager. 18 | - [Docusaurus](https://docusaurus.io/) for the documentation website. 19 | 20 | ### What you need to install 21 | 22 | - `git`; 23 | - Node.js LTS version specified in [.nvmrc](./.nvmrc). Highly recommended to use [fnm](https://github.com/Schniz/fnm) or [nvm](https://github.com/nvm-sh/nvm) for easy management of Node.js versions; 24 | - a code editor: [VS Code](https://code.visualstudio.com) is the recommended one, as it enables workspace specific [settings](./.vscode/settings.json) and [extensions](./.vscode/extensions.json) to make the development more user-friendly; 25 | - [`pnpm`](https://pnpm.io/installation) as package manager. 26 | 27 | ### Repository structure 28 | 29 | - [`packages/next-safe-action`](./packages/next-safe-action): contains the source code of the library; 30 | - [`apps/playground`](./apps/playground): contains the source code of the Next.js playground app, which is a basic implementation of the library; 31 | - [`website`](./website): contains the source code of the [next-safe-action website](https://next-safe-action.dev). 32 | 33 | ## How to contribute 34 | 35 | ### Getting started 36 | 37 | Before opening a pull request, please follow the general rule of **opening an issue or discussion first**, using the [issue templates](https://github.com/TheEdoRan/next-safe-action/issues/new/choose), that will guide you through the process. You can avoid opening a new issue or discussion if: 38 | 39 | - You're correcting a trivial error, like a typo; 40 | - The issue or discussion for the bug you're fixing/feature you're implementing with the PR is already open. 41 | 42 | ### Development setup 43 | 44 | After forking, cloning the repository and optionally creating a new branch from the base one, you can install the dependencies using `pnpm` in the project root directory: 45 | 46 | ```sh 47 | pnpm install 48 | ``` 49 | 50 | Then, you can run the `build:lib` command to rebuild the library code, and then test it in the playground app: 51 | 52 | ```sh 53 | pnpm run build:lib && pnpm run pg 54 | ``` 55 | 56 | > [!TIP] 57 | > If you see many type errors in the playground app after running the `build:lib` command, try to restart the TS Server of VS Code. This should fix the errors. 58 | 59 | If you updated user facing APIs of the library, you're **not required**, but **highly incouraged** to: 60 | - update [the documentation](./website/docs) of the library to reflect the changes you've made. 61 | - write tests for the changes you've made. They should be placed in the appropriate file inside [`__tests__`](./packages/next-safe-action/src/__tests__) directory (`next-safe-action` package). 62 | 63 | These steps can be done in later stages of the PR too, for instance when a maintainer already approved your code updates. 64 | 65 | Note that the [`website`](./website) project is not part of the monorepo packages, so you need to `cd` into it and then run this command to install its dependencies: 66 | 67 | ```sh 68 | pnpm install 69 | ``` 70 | 71 | Then you can start the Docusaurus development server with: 72 | 73 | ```sh 74 | pnpm run start 75 | ``` 76 | 77 | ### Committing changes 78 | 79 | Once you're done with your code changes, you can finally commit and push them to the remote repository. 80 | 81 | Committing is very easy, thanks to both `commitizen` and `commitlint` utilities. Each commit message **must** follow the [Conventional Commits](https://www.conventionalcommits.org/) format, to allow for automated release management via `semantic-release`. You can commit your code using: 82 | 83 | ```sh 84 | git commit --no-edit 85 | ``` 86 | 87 | This command will bring up the `commitizen` interface to help you write a proper commit message, without also bringing up the default editor. If you want to, you can set up an alias for it, to make it easier to type and remember. The commit message is then run through `commitlint` to validate it. 88 | 89 | Changes made in `website` or `playground` scopes **must** be typed `chore()`, since they are not part of the library code. 90 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Edoardo Ranghieri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | next-safe-action logo 3 |

next-safe-action

4 |
5 | 6 | **next-safe-action** is a library that takes full advantage of the latest and greatest Next.js, React and TypeScript features to let you define **type safe** Server Actions and execute them inside React Components. 7 | 8 | ## Features 9 | 10 | - ✅ Pretty simple 11 | - ✅ End-to-end type safety 12 | - ✅ Form Actions support 13 | - ✅ Powerful middleware system 14 | - ✅ Input/output validation using multiple validation libraries 15 | - ✅ Advanced server error handling 16 | - ✅ Optimistic updates 17 | 18 | ## Documentation 19 | 20 | **Explore the documentation for the current stable version of the library on the [next-safe-action v7 website](https://next-safe-action.dev).** ✨ 21 | 22 | ### Looking for v7 docs? 23 | 24 | You can keep using version 6 and eventually upgrade to version 7. Check out the v7 documentation [here](https://v7.next-safe-action.dev). 25 | 26 | ## Migrate from v7 to v8 27 | 28 | Check out the [v7 to v8 migration guide](https://next-safe-action.dev/docs/migrations/v7-to-v8) to learn how to update your code for v8. 29 | 30 | ## Installation 31 | 32 | ```bash 33 | npm i next-safe-action 34 | ``` 35 | 36 | ## Playground 37 | 38 | You can find a basic working implementation of the library [here](https://github.com/TheEdoRan/next-safe-action/tree/main/apps/playground). 39 | 40 | ## Sponsors 41 | 42 | A big shout-out to all our [sponsors](https://github.com/sponsors/TheEdoRan)! You’re the driving force behind this library's growth, and we're truly grateful for your support. ❤️ 43 | 44 |

45 | 46 | 47 | 48 |

49 | 50 | ## Contributing 51 | 52 | If you want to contribute to next-safe-action, please check out the [contributing guide](https://github.com/TheEdoRan/next-safe-action/blob/main/CONTRIBUTING.md). 53 | 54 | If you found bugs or just want to ask a question, feel free to open an issue or a discussion by following the [issue templates](https://github.com/TheEdoRan/next-safe-action/issues/new/choose). 55 | 56 | ## Contributors 57 | 58 | 59 | 60 | 61 | 62 | Made with [contrib.rocks](https://contrib.rocks). 63 | 64 | ## License 65 | 66 | next-safe-action is released under the [MIT License](https://github.com/TheEdoRan/next-safe-action/blob/main/LICENSE). 67 | -------------------------------------------------------------------------------- /apps/playground/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "next/core-web-vitals" 4 | } 5 | -------------------------------------------------------------------------------- /apps/playground/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /apps/playground/README.md: -------------------------------------------------------------------------------- 1 | Try it yourself: [Link to example on Vercel](https://next-safe-action-playground.vercel.app/). 2 | 3 | This is a basic implementation of the [next-safe-action](../../packages/next-safe-action) library. 4 | -------------------------------------------------------------------------------- /apps/playground/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | authInterrupts: true, 5 | }, 6 | }; 7 | 8 | module.exports = nextConfig; 9 | -------------------------------------------------------------------------------- /apps/playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apps/playground", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "author": "Edoardo Ranghieri", 12 | "dependencies": { 13 | "@hookform/resolvers": "^4.1.3", 14 | "lucide-react": "^0.483.0", 15 | "next": "catalog:", 16 | "next-safe-action": "workspace:*", 17 | "react": "catalog:", 18 | "react-dom": "catalog:", 19 | "react-hook-form": "^7.54.2", 20 | "zod": "^3.24.2", 21 | "zod-form-data": "^2.0.7" 22 | }, 23 | "devDependencies": { 24 | "@types/node": "^22", 25 | "@types/react": "^19", 26 | "@types/react-dom": "^19", 27 | "autoprefixer": "10.4.21", 28 | "eslint": "catalog:", 29 | "eslint-config-next": "catalog:", 30 | "postcss": "8.5.3", 31 | "tailwindcss": "^3", 32 | "typescript": "catalog:" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /apps/playground/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/async-schema/login-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { flattenValidationErrors, returnValidationErrors } from "next-safe-action"; 5 | import { z } from "zod"; 6 | 7 | async function getSchema() { 8 | return z.object({ 9 | username: z.string().min(3).max(10), 10 | password: z.string().min(8).max(100), 11 | }); 12 | } 13 | 14 | export const loginUser = action 15 | .metadata({ actionName: "loginUser" }) 16 | .inputSchema(getSchema, { 17 | // Here we use the `flattenValidationErrors` function to customize the returned validation errors 18 | // object to the client. 19 | handleValidationErrorsShape: async (ve) => flattenValidationErrors(ve).fieldErrors, 20 | }) 21 | .action(async ({ parsedInput: { username, password } }) => { 22 | if (username === "johndoe") { 23 | returnValidationErrors(getSchema, { 24 | username: { 25 | _errors: ["user_suspended"], 26 | }, 27 | }); 28 | } 29 | 30 | if (username === "user" && password === "password") { 31 | return { 32 | success: true, 33 | }; 34 | } 35 | 36 | returnValidationErrors(getSchema, { 37 | username: { 38 | _errors: ["incorrect_credentials"], 39 | }, 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/async-schema/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { StyledInput } from "@/app/_components/styled-input"; 6 | import { useState } from "react"; 7 | import { ResultBox } from "../../_components/result-box"; 8 | import { loginUser } from "./login-action"; 9 | 10 | export default function AsyncSchemaPage() { 11 | const [result, setResult] = useState(undefined); 12 | 13 | return ( 14 |
15 | 16 | Action using direct call 17 |
18 | (async schema) 19 |
20 |
{ 23 | e.preventDefault(); 24 | const formData = new FormData(e.currentTarget); 25 | const input = Object.fromEntries(formData) as { 26 | username: string; 27 | password: string; 28 | }; 29 | const res = await loginUser(input); // this is the type safe action directly called 30 | setResult(res); 31 | }} 32 | > 33 | 34 | 35 | Log in 36 | 37 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/bind-arguments/onboard-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { z } from "zod"; 5 | 6 | const schema = z.object({ 7 | username: z.string().min(3).max(30), 8 | }); 9 | 10 | const bindArgsSchemas: [userId: z.ZodString, age: z.ZodNumber] = [z.string().uuid(), z.number().min(18).max(150)]; 11 | 12 | export const onboardUser = action 13 | .metadata({ actionName: "onboardUser" }) 14 | .inputSchema(schema) 15 | .bindArgsSchemas(bindArgsSchemas) 16 | .action(async ({ parsedInput: { username }, bindArgsParsedInputs: [userId, age] }) => { 17 | await new Promise((res) => setTimeout(res, 1000)); 18 | 19 | return { 20 | message: `Welcome on board, ${username}! (age = ${age}, user id = ${userId})`, 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/bind-arguments/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { StyledInput } from "@/app/_components/styled-input"; 6 | import { useAction } from "next-safe-action/hooks"; 7 | import { ResultBox } from "../../_components/result-box"; 8 | import { onboardUser } from "./onboard-action"; 9 | 10 | export default function BindArguments() { 11 | const boundOnboardUser = onboardUser.bind(null, crypto.randomUUID(), Math.floor(Math.random() * 200)); 12 | 13 | const { execute, result, status, reset } = useAction(boundOnboardUser); 14 | 15 | console.log("status:", status); 16 | 17 | return ( 18 |
19 | Action binding arguments 20 |
{ 23 | e.preventDefault(); 24 | const formData = new FormData(e.currentTarget); 25 | const input = Object.fromEntries(formData) as { 26 | username: string; 27 | }; 28 | 29 | // Action call. 30 | execute(input); 31 | }} 32 | > 33 | 34 | Onboard user 35 | 36 | Reset 37 | 38 | 39 | 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/direct/login-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { flattenValidationErrors, returnValidationErrors } from "next-safe-action"; 5 | import { z } from "zod"; 6 | 7 | const schema = z.object({ 8 | username: z.string().min(3).max(10), 9 | password: z.string().min(8).max(100), 10 | }); 11 | 12 | export const loginUser = action 13 | .metadata({ actionName: "loginUser" }) 14 | .inputSchema(schema, { 15 | // Here we use the `flattenValidationErrors` function to customize the returned validation errors 16 | // object to the client. 17 | handleValidationErrorsShape: async (ve) => flattenValidationErrors(ve).fieldErrors, 18 | }) 19 | .action( 20 | async ({ parsedInput: { username, password } }) => { 21 | if (username === "johndoe") { 22 | returnValidationErrors(schema, { 23 | username: { 24 | _errors: ["user_suspended"], 25 | }, 26 | }); 27 | } 28 | 29 | if (username === "user" && password === "password") { 30 | return { 31 | success: true, 32 | }; 33 | } 34 | 35 | returnValidationErrors(schema, { 36 | username: { 37 | _errors: ["incorrect_credentials"], 38 | }, 39 | }); 40 | }, 41 | { 42 | onSuccess: async (args) => { 43 | console.log("Logging from onSuccess callback:"); 44 | console.dir(args, { depth: null }); 45 | }, 46 | onError: async (args) => { 47 | console.log("Logging from onError callback:"); 48 | console.dir(args, { depth: null }); 49 | }, 50 | onSettled: async (args) => { 51 | console.log("Logging from onSettled callback:"); 52 | console.dir(args, { depth: null }); 53 | }, 54 | } 55 | ); 56 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/direct/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { StyledInput } from "@/app/_components/styled-input"; 6 | import { useState } from "react"; 7 | import { ResultBox } from "../../_components/result-box"; 8 | import { loginUser } from "./login-action"; 9 | 10 | export default function DirectExamplePage() { 11 | const [result, setResult] = useState({}); 12 | 13 | return ( 14 |
15 | Action using direct call 16 |
{ 19 | e.preventDefault(); 20 | const formData = new FormData(e.currentTarget); 21 | const input = Object.fromEntries(formData) as { 22 | username: string; 23 | password: string; 24 | }; 25 | const res = await loginUser(input); // this is the typesafe action directly called 26 | setResult(res); 27 | }} 28 | > 29 | 30 | 31 | Log in 32 | 33 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/empty-response/empty-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { z } from "zod"; 5 | 6 | const schema = z.object({ 7 | userId: z.string().uuid(), 8 | }); 9 | 10 | export const emptyAction = action 11 | .metadata({ actionName: "emptyAction" }) 12 | .inputSchema(schema) 13 | .action(async () => { 14 | await new Promise((res) => setTimeout(res, 500)); 15 | }); 16 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/empty-response/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { useAction } from "next-safe-action/hooks"; 6 | import { ResultBox } from "../../_components/result-box"; 7 | import { emptyAction } from "./empty-action"; 8 | 9 | export default function EmptyResponse() { 10 | const { execute, result, status, reset } = useAction(emptyAction); 11 | 12 | console.log("status:", status); 13 | 14 | return ( 15 |
16 | Action without response data 17 | { 21 | execute({ userId: crypto.randomUUID() }); 22 | }} 23 | > 24 | Execute action 25 | 26 | 27 | Reset 28 | 29 | 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/file-upload/file-upload-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { zfd } from "zod-form-data"; 5 | 6 | const schema = zfd.formData({ 7 | image: zfd.file(), 8 | }); 9 | 10 | export const fileUploadAction = action 11 | .metadata({ actionName: "fileUploadAction" }) 12 | .inputSchema(schema) 13 | .action(async ({ parsedInput }) => { 14 | await new Promise((res) => setTimeout(res, 1000)); 15 | 16 | // Do something useful with the file. 17 | console.log("fileUploadAction ->", parsedInput); 18 | 19 | return { 20 | ok: true, 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/file-upload/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ResultBox } from "@/app/_components/result-box"; 4 | import { StyledButton } from "@/app/_components/styled-button"; 5 | import { StyledHeading } from "@/app/_components/styled-heading"; 6 | import { StyledInput } from "@/app/_components/styled-input"; 7 | import { useAction } from "next-safe-action/hooks"; 8 | import { fileUploadAction } from "./file-upload-action"; 9 | 10 | export default function FileUploadPage() { 11 | const { execute, result, status, input } = useAction(fileUploadAction); 12 | 13 | console.log("INPUT ->", input); 14 | console.log("RESULT ->", result); 15 | 16 | return ( 17 |
18 | File upload action 19 |
20 | 21 | Submit 22 | 23 | 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/hook/deleteuser-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { ActionError, action } from "@/lib/safe-action"; 4 | import { z } from "zod"; 5 | 6 | const schema = z.object({ 7 | userId: z.string().min(1).max(10), 8 | }); 9 | 10 | export const deleteUser = action 11 | .metadata({ actionName: "deleteUser" }) 12 | .inputSchema(schema) 13 | .action(async ({ parsedInput: { userId } }) => { 14 | await new Promise((res) => setTimeout(res, 1000)); 15 | 16 | if (Math.random() > 0.5) { 17 | throw new ActionError("Could not delete user!"); 18 | } 19 | 20 | return { 21 | deletedUserId: userId, 22 | }; 23 | }); 24 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/hook/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { StyledInput } from "@/app/_components/styled-input"; 6 | import { useAction } from "next-safe-action/hooks"; 7 | import { ResultBox } from "../../_components/result-box"; 8 | import { deleteUser } from "./deleteuser-action"; 9 | 10 | export default function Hook() { 11 | // Safe action (`deleteUser`) and optional callbacks passed to `useAction` hook. 12 | const { 13 | execute, 14 | executeAsync, 15 | result, 16 | status, 17 | reset, 18 | isIdle, 19 | isExecuting, 20 | isTransitioning, 21 | hasSucceeded, 22 | hasErrored, 23 | } = useAction(deleteUser, { 24 | onSuccess(args) { 25 | console.log("onSuccess callback:", args); 26 | }, 27 | onError(args) { 28 | console.log("onError callback:", args); 29 | }, 30 | onNavigation(args) { 31 | console.log("onNavigation callback:", args); 32 | }, 33 | onSettled(args) { 34 | console.log("onSettled callback:", args); 35 | }, 36 | onExecute(args) { 37 | console.log("onExecute callback:", args); 38 | }, 39 | }); 40 | 41 | console.dir({ 42 | status, 43 | isIdle, 44 | isExecuting, 45 | isTransitioning, 46 | hasSucceeded, 47 | hasErrored, 48 | }); 49 | 50 | return ( 51 |
52 | Action using hook 53 |
{ 56 | e.preventDefault(); 57 | const formData = new FormData(e.currentTarget); 58 | const input = Object.fromEntries(formData) as { 59 | userId: string; 60 | }; 61 | 62 | // Action call. Here we use `executeAsync` that lets us await the result. You can also use the `execute` function, 63 | // which is synchronous. 64 | const r = await executeAsync(input); 65 | console.log("r", r); 66 | }} 67 | > 68 | 69 | Delete user 70 | 71 | Reset 72 | 73 | 74 | 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronLeft } from "lucide-react"; 2 | import Link from "next/link"; 3 | import { type ReactNode } from "react"; 4 | import { ExampleGithubLink } from "../_components/example-github-link"; 5 | 6 | export default function ExamplesLayout({ children }: { children: ReactNode }) { 7 | return ( 8 |
9 |
10 | 11 | 12 | Go back 13 | 14 | 15 |
16 |
{children}
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/navigation/navigation-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { forbidden, notFound, redirect, unauthorized } from "next/navigation"; 5 | import { z } from "zod"; 6 | 7 | const schema = z.object({ 8 | kind: z.enum(["redirect", "notFound", "forbidden", "unauthorized", "happy-path"]), 9 | }); 10 | 11 | export const testNavigate = action 12 | .metadata({ actionName: "testNavigate" }) 13 | .inputSchema(schema) 14 | .action( 15 | async ({ parsedInput: { kind } }) => { 16 | await new Promise((res) => setTimeout(res, 1000)); 17 | 18 | switch (kind) { 19 | case "redirect": 20 | redirect("/"); 21 | case "notFound": 22 | notFound(); 23 | case "forbidden": 24 | forbidden(); 25 | case "unauthorized": 26 | unauthorized(); 27 | default: 28 | return { 29 | success: true, 30 | }; 31 | } 32 | }, 33 | { 34 | async onSuccess(args) { 35 | console.log("ON SUCCESS CALLBACK", args); 36 | }, 37 | async onError(args) { 38 | console.log("ON ERROR CALLBACK", args); 39 | }, 40 | async onSettled(args) { 41 | console.log("ON SETTLED CALLBACK", args); 42 | }, 43 | async onNavigation(args) { 44 | console.log("ON NAVIGATION CALLBACK", args); 45 | }, 46 | } 47 | ); 48 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/navigation/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { useAction } from "next-safe-action/hooks"; 6 | import { ResultBox } from "../../_components/result-box"; 7 | import { testNavigate } from "./navigation-action"; 8 | 9 | export default function Navigation() { 10 | // Safe action (`deleteUser`) and optional callbacks passed to `useAction` hook. 11 | const { 12 | execute, 13 | executeAsync, 14 | result, 15 | status, 16 | reset, 17 | isIdle, 18 | isExecuting, 19 | isTransitioning, 20 | hasSucceeded, 21 | hasErrored, 22 | } = useAction(testNavigate, { 23 | onSuccess(args) { 24 | console.log("onSuccess callback:", args); 25 | }, 26 | onError(args) { 27 | console.log("onError callback:", args); 28 | }, 29 | onNavigation(args) { 30 | console.log("onNavigation callback:", args); 31 | }, 32 | onSettled(args) { 33 | console.log("onSettled callback:", args); 34 | }, 35 | onExecute(args) { 36 | console.log("onExecute callback:", args); 37 | }, 38 | }); 39 | 40 | console.dir({ 41 | result, 42 | status, 43 | isIdle, 44 | isExecuting, 45 | isTransitioning, 46 | hasSucceeded, 47 | hasErrored, 48 | }); 49 | 50 | return ( 51 |
52 | Action using hook 53 |
54 | execute({ kind: "redirect" })}> 55 | Redirect 56 | 57 | execute({ kind: "notFound" })}> 58 | Not found 59 | 60 | execute({ kind: "forbidden" })}> 61 | Forbidden 62 | 63 | execute({ kind: "unauthorized" })}> 64 | Unauthorized 65 | 66 | execute({ kind: "happy-path" })}> 67 | Happy path 68 | 69 | 70 | Reset 71 | 72 |
73 | 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/nested-schema/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { useAction } from "next-safe-action/hooks"; 6 | import { ResultBox } from "../../_components/result-box"; 7 | import { buyProduct } from "./shop-action"; 8 | 9 | export default function NestedSchemaPage() { 10 | const { execute, result, status } = useAction(buyProduct); 11 | 12 | return ( 13 |
14 | Action using nested schema 15 |
{ 18 | e.preventDefault(); 19 | 20 | // Change one of these two to generate validation errors. 21 | const userId = crypto.randomUUID(); 22 | const productId = crypto.randomUUID(); 23 | 24 | execute({ 25 | user: { id: userId }, 26 | product: { deeplyNested: { id: productId } }, 27 | }); // this is the typesafe action called from client 28 | }} 29 | > 30 | Buy product 31 |
32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/nested-schema/shop-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { z } from "zod"; 5 | 6 | const schema = z 7 | .object({ 8 | user: z.object({ 9 | id: z.string().uuid(), 10 | }), 11 | product: z.object({ 12 | deeplyNested: z.object({ 13 | id: z.string().uuid(), 14 | }), 15 | }), 16 | }) 17 | .superRefine((_, ctx) => { 18 | // Randomly generate validation error for root. 19 | if (Math.random() > 0.5) { 20 | ctx.addIssue({ 21 | code: "custom", 22 | message: "Parent schema error", 23 | }); 24 | } 25 | 26 | // Randomly generate validation error for user object. 27 | if (Math.random() > 0.5) { 28 | ctx.addIssue({ 29 | code: "custom", 30 | path: ["user"], 31 | message: "Parent user error", 32 | }); 33 | ctx.addIssue({ 34 | code: "custom", 35 | path: ["user"], 36 | message: "Parent user error 2", 37 | }); 38 | } 39 | 40 | // Randomly generate validation error for user id. 41 | if (Math.random() > 0.5) { 42 | ctx.addIssue({ 43 | code: "custom", 44 | path: ["user", "id"], 45 | message: "Another bad user id error", 46 | }); 47 | } 48 | 49 | // Randomly generate validation errors for product object. 50 | if (Math.random() > 0.5) { 51 | ctx.addIssue({ 52 | code: "custom", 53 | path: ["product"], 54 | message: "Parent product error", 55 | }); 56 | 57 | ctx.addIssue({ 58 | code: "custom", 59 | path: ["product", "deeplyNested"], 60 | message: "Deeply nested product error", 61 | }); 62 | 63 | ctx.addIssue({ 64 | code: "custom", 65 | path: ["product", "deeplyNested", "id"], 66 | message: "Product not found in the store", 67 | }); 68 | } 69 | }); 70 | 71 | export const buyProduct = action 72 | .metadata({ actionName: "buyProduct" }) 73 | .inputSchema(schema) 74 | .action(async () => { 75 | return { 76 | success: true, 77 | }; 78 | }); 79 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/no-arguments/noargs-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | 5 | export const noargsAction = action.metadata({ actionName: "noargsAction" }).action(async () => { 6 | await new Promise((res) => setTimeout(res, 500)); 7 | 8 | return { 9 | message: "Well done!", 10 | }; 11 | }); 12 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/no-arguments/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { useAction } from "next-safe-action/hooks"; 6 | import { ResultBox } from "../../_components/result-box"; 7 | import { noargsAction } from "./noargs-action"; 8 | 9 | export default function EmptySchema() { 10 | const { execute, result, status, reset } = useAction(noargsAction); 11 | 12 | console.log("status:", status); 13 | 14 | return ( 15 |
16 | Action without arguments 17 |
{ 20 | e.preventDefault(); 21 | // Action call. 22 | execute(); 23 | }} 24 | > 25 | Execute action 26 | 27 | Reset 28 | 29 |
30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/optimistic-hook/addtodo-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { ActionError, action } from "@/lib/safe-action"; 4 | import { revalidatePath } from "next/cache"; 5 | import { z } from "zod"; 6 | 7 | const schema = z.object({ 8 | id: z.string().uuid(), 9 | body: z.string().min(1), 10 | completed: z.boolean(), 11 | }); 12 | 13 | export type Todo = z.infer; 14 | 15 | let todos: Todo[] = []; 16 | export const getTodos = async () => todos; 17 | 18 | export const addTodo = action 19 | .metadata({ actionName: "" }) 20 | .inputSchema(schema) 21 | .action(async ({ parsedInput }) => { 22 | await new Promise((res) => setTimeout(res, 500)); 23 | 24 | if (Math.random() > 0.5) { 25 | throw new ActionError("Could not add todo right now, please try again later."); 26 | } 27 | 28 | todos.push(parsedInput); 29 | 30 | // This Next.js function revalidates the provided path. 31 | // More info here: https://nextjs.org/docs/app/api-reference/functions/revalidatePath 32 | revalidatePath("/optimistic-hook"); 33 | 34 | return { 35 | newTodo: parsedInput, 36 | }; 37 | }); 38 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/optimistic-hook/addtodo-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledInput } from "@/app/_components/styled-input"; 5 | import { useOptimisticAction } from "next-safe-action/hooks"; 6 | import { ResultBox } from "../../_components/result-box"; 7 | import { Todo, addTodo } from "./addtodo-action"; 8 | 9 | type Props = { 10 | todos: Todo[]; 11 | }; 12 | 13 | const AddTodoForm = ({ todos }: Props) => { 14 | // Here we pass safe action (`addTodo`) and current server data to `useOptimisticAction` hook. 15 | const { execute, result, status, reset, optimisticState } = useOptimisticAction(addTodo, { 16 | currentState: { todos }, 17 | updateFn: (state, newTodo) => ({ 18 | todos: [...state.todos, newTodo], 19 | }), 20 | onSuccess(args) { 21 | console.log("onSuccess callback:", args); 22 | }, 23 | onError(args) { 24 | console.log("onError callback:", args); 25 | }, 26 | onNavigation(args) { 27 | console.log("onNavigation callback:", args); 28 | }, 29 | onSettled(args) { 30 | console.log("onSettled callback:", args); 31 | }, 32 | onExecute(args) { 33 | console.log("onExecute callback:", args); 34 | }, 35 | }); 36 | 37 | console.log("status:", status); 38 | 39 | return ( 40 | <> 41 |
{ 44 | e.preventDefault(); 45 | const formData = new FormData(e.currentTarget); 46 | const body = formData.get("body") as string; 47 | 48 | // Action call. Here we pass action input and expected (optimistic) 49 | // data. 50 | execute({ id: crypto.randomUUID(), body, completed: false }); 51 | }} 52 | > 53 | 54 | Add todo 55 | 56 | Reset 57 | 58 | 59 | 60 | 61 | ); 62 | }; 63 | 64 | export default AddTodoForm; 65 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/optimistic-hook/page.tsx: -------------------------------------------------------------------------------- 1 | import { StyledHeading } from "@/app/_components/styled-heading"; 2 | import { getTodos } from "./addtodo-action"; 3 | import AddTodoForm from "./addtodo-form"; 4 | 5 | export default async function OptimisticHookPage() { 6 | const todos = await getTodos(); 7 | 8 | return ( 9 |
10 | Action using optimistic hook 11 | {/* Pass the server state to Client Component */} 12 | 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/react-hook-form/buyproduct-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { randomUUID } from "crypto"; 5 | import { schema } from "./validation"; 6 | 7 | export const buyProduct = action 8 | .metadata({ actionName: "buyProduct" }) 9 | .inputSchema(schema) 10 | .action(async ({ parsedInput: { productId } }) => { 11 | return { 12 | productId, 13 | transactionId: randomUUID(), 14 | transactionTimestamp: Date.now(), 15 | }; 16 | }); 17 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/react-hook-form/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ResultBox } from "@/app/_components/result-box"; 4 | import { StyledButton } from "@/app/_components/styled-button"; 5 | import { StyledHeading } from "@/app/_components/styled-heading"; 6 | import { StyledInput } from "@/app/_components/styled-input"; 7 | import { zodResolver } from "@hookform/resolvers/zod"; 8 | import { useState } from "react"; 9 | import { useForm } from "react-hook-form"; 10 | import { z } from "zod"; 11 | import { buyProduct } from "./buyproduct-action"; 12 | import { schema } from "./validation"; 13 | 14 | export default function ReactHookFormPage() { 15 | const { register, handleSubmit } = useForm>({ 16 | resolver: zodResolver(schema), 17 | }); 18 | 19 | const [result, setResult] = useState({}); 20 | 21 | return ( 22 |
23 | Action using React Hook Form 24 |
{ 27 | const res = await buyProduct(data); 28 | setResult(res); 29 | })} 30 | > 31 | 32 | Buy product 33 | 34 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/react-hook-form/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const schema = z.object({ 4 | productId: z.string().min(1), 5 | }); 6 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/stateful-form/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ResultBox } from "@/app/_components/result-box"; 4 | import { StyledButton } from "@/app/_components/styled-button"; 5 | import { StyledHeading } from "@/app/_components/styled-heading"; 6 | import { StyledInput } from "@/app/_components/styled-input"; 7 | import { useActionState } from "react"; 8 | import { statefulFormAction } from "./stateful-form-action"; 9 | 10 | export default function StatefulFormPage() { 11 | const [state, action, isPending] = useActionState(statefulFormAction, { 12 | data: { newName: "jane" }, // optionally pass initial state 13 | }); 14 | 15 | return ( 16 |
17 | 18 | Stateful form action using
useActionState()
19 |
20 |
21 | 22 | Submit 23 | 24 |

25 | isPending: {JSON.stringify(isPending)} 26 |

27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/stateful-form/stateful-form-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { z } from "zod"; 5 | import { zfd } from "zod-form-data"; 6 | 7 | const schema = zfd.formData({ 8 | name: zfd.text(z.string().min(1).max(20)), 9 | }); 10 | 11 | // Note that we need to explicitly give a type to `stateAction` here, for its return object. 12 | // This is because TypeScript can't infer the return type of the function and then "pass it" to 13 | // the second argument of the server code function (`prevResult`). If you don't need to access `prevResult`, 14 | // though, you can omit the type here, since it will be inferred just like with `action` method. 15 | export const statefulFormAction = action 16 | .metadata({ actionName: "statefulFormAction" }) 17 | .inputSchema(schema) 18 | .stateAction<{ 19 | prevName?: string; 20 | newName: string; 21 | }>(async ({ parsedInput, metadata }, { prevResult }) => { 22 | await new Promise((res) => setTimeout(res, 1000)); 23 | 24 | return { 25 | prevName: prevResult.data?.newName, 26 | newName: parsedInput.name, 27 | }; 28 | }); 29 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/stateless-form/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ResultBox } from "@/app/_components/result-box"; 4 | import { StyledButton } from "@/app/_components/styled-button"; 5 | import { StyledHeading } from "@/app/_components/styled-heading"; 6 | import { StyledInput } from "@/app/_components/styled-input"; 7 | import { useAction } from "next-safe-action/hooks"; 8 | import { statelessFormAction } from "./stateless-form-action"; 9 | 10 | export default function StatelessFormPage() { 11 | const { execute, result, status, input } = useAction(statelessFormAction); 12 | 13 | console.log("INPUT ->", input); 14 | console.log("RESULT ->", result); 15 | 16 | return ( 17 |
18 | 19 | Stateless form action using
useAction()
20 |
21 |
22 | 23 | Submit 24 | 25 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/stateless-form/stateless-form-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { action } from "@/lib/safe-action"; 4 | import { z } from "zod"; 5 | import { zfd } from "zod-form-data"; 6 | 7 | const schema = zfd.formData({ 8 | name: zfd.text(z.string().min(1).max(20)), 9 | }); 10 | 11 | export const statelessFormAction = action 12 | .metadata({ actionName: "statelessFormAction" }) 13 | .inputSchema(schema) 14 | .action(async ({ parsedInput }) => { 15 | await new Promise((res) => setTimeout(res, 1000)); 16 | 17 | return { 18 | newName: parsedInput.name, 19 | }; 20 | }); 21 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/with-context/edituser-action.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { authAction } from "@/lib/safe-action"; 4 | import { z } from "zod"; 5 | 6 | const schema = z.object({ 7 | fullName: z.string().min(3).max(20), 8 | age: z.string().min(2).max(3), 9 | }); 10 | 11 | export const editUser = authAction 12 | .metadata({ actionName: "editUser" }) 13 | .inputSchema(schema) 14 | .action( 15 | // Here you have access to `userId`, and `sessionId which comes from middleware functions 16 | // defined before. 17 | // \\\\\\\\\\\\\\\\\\ 18 | async ({ parsedInput: { fullName, age }, ctx: { userId, sessionId } }) => { 19 | if (fullName.toLowerCase() === "john doe") { 20 | return { 21 | error: { 22 | cause: "forbidden_name", 23 | }, 24 | }; 25 | } 26 | 27 | const intAge = parseInt(age); 28 | 29 | if (Number.isNaN(intAge)) { 30 | return { 31 | error: { 32 | reason: "invalid_age", // different key in `error`, will be correctly inferred 33 | }, 34 | }; 35 | } 36 | 37 | return { 38 | success: { 39 | newFullName: fullName, 40 | newAge: intAge, 41 | userId, 42 | sessionId, 43 | }, 44 | }; 45 | } 46 | ); 47 | -------------------------------------------------------------------------------- /apps/playground/src/app/(examples)/with-context/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { StyledButton } from "@/app/_components/styled-button"; 4 | import { StyledHeading } from "@/app/_components/styled-heading"; 5 | import { StyledInput } from "@/app/_components/styled-input"; 6 | import { useAction } from "next-safe-action/hooks"; 7 | import { ResultBox } from "../../_components/result-box"; 8 | import { editUser } from "./edituser-action"; 9 | 10 | export default function WithContextPage() { 11 | const { execute, result, status } = useAction(editUser); 12 | 13 | return ( 14 |
15 | Action with auth 16 |
{ 19 | e.preventDefault(); 20 | const formData = new FormData(e.currentTarget); 21 | const input = Object.fromEntries(formData) as { 22 | fullName: string; 23 | age: string; 24 | }; 25 | execute(input); 26 | }} 27 | > 28 | 29 | 30 | Update profile 31 | 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /apps/playground/src/app/_components/example-github-link.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Link } from "lucide-react"; 4 | import { usePathname } from "next/navigation"; 5 | 6 | type Props = { 7 | className?: string; 8 | }; 9 | 10 | export function ExampleGithubLink({ className }: Props) { 11 | const pathname = usePathname(); 12 | 13 | return ( 14 | 21 | 22 | View on GitHub 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/playground/src/app/_components/example-link.tsx: -------------------------------------------------------------------------------- 1 | import { Link as LinkIcon } from "lucide-react"; 2 | import Link from "next/link"; 3 | import type { ReactNode } from "react"; 4 | 5 | type Props = { 6 | href: string; 7 | children: ReactNode; 8 | }; 9 | 10 | export function ExampleLink({ href, children }: Props) { 11 | return ( 12 | 13 | 14 | 15 | {children} 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/playground/src/app/_components/result-box.tsx: -------------------------------------------------------------------------------- 1 | import type { HookActionStatus } from "next-safe-action/hooks"; 2 | 3 | type Props = { 4 | result?: any; 5 | status?: HookActionStatus; 6 | customTitle?: string; 7 | }; 8 | 9 | export function ResultBox({ result, status, customTitle }: Props) { 10 | return ( 11 |
12 | {status ?

Execution status: {status}

: null} 13 |

{customTitle || "Action result:"}

14 |
{JSON.stringify(result, null, 1)}
15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/playground/src/app/_components/styled-button.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, type ComponentProps } from "react"; 2 | 3 | type Props = ComponentProps<"button">; 4 | 5 | export const StyledButton = forwardRef(function StyledButton(props: Props, ref) { 6 | return ( 7 | 56 | ); 57 | } 58 | ``` 59 | 60 | ### Action result object 61 | 62 | Every action you execute returns an object with the same structure. This is described in the [action result object](/docs/define-actions/action-result-object) section. 63 | 64 | Explore a working example [here](). 65 | -------------------------------------------------------------------------------- /website/docs/execute-actions/hooks/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Hooks", 3 | "position": 2 4 | } 5 | -------------------------------------------------------------------------------- /website/docs/execute-actions/hooks/hook-callbacks.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 5 3 | description: Hook callbacks are a way to perform custom logic based on the current action execution status. 4 | --- 5 | 6 | # Hook callbacks 7 | 8 | - `onExecute`: called when `status` is `"executing"`. 9 | - `onSuccess`: called when `status` is `"hasSucceeded"`. 10 | - `onError`: called when `status` is `"hasErrored"`. 11 | - `onNavigation`: called when `status` is `"hasNavigated"`. 12 | - `onSettled`: called when `status` is either `"hasSucceeded"`, `"hasErrored"` or `"hasNavigated"`. 13 | 14 | Hook callbacks are a way to perform custom logic based on the current action execution status. You can pass them to the three hooks in the `utils` object, which is the second argument. All of them are optional and don't return anything, they can also be async or not: 15 | 16 | ```tsx 17 | const action = useAction(testAction, { 18 | onExecute: ({ input }) => {}, 19 | onSuccess: ({ data, input }) => {}, 20 | onError: ({ error, input }) => {}, 21 | onNavigation: ({ input, navigationKind }) => {}, 22 | onSettled: ({ result, input, navigationKind }) => {}, 23 | }); 24 | ``` -------------------------------------------------------------------------------- /website/docs/execute-actions/hooks/useaction.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | description: Learn how to use the useAction hook. 4 | --- 5 | 6 | # `useAction()` 7 | 8 | :::info 9 | `useAction()` **waits** for the action to finish execution before returning the result. If you need to perform optimistic updates, use [`useOptimisticAction()`](/docs/execute-actions/hooks/useoptimisticaction) instead. 10 | ::: 11 | 12 | With this hook, you get full control of the action execution flow. 13 | Let's say, for instance, you want to change what's displayed by a component when a button is clicked. 14 | 15 | ### Example 16 | 17 | 1. Define a new action called `greetUser()`, that takes a name as input and returns a greeting: 18 | 19 | ```typescript title=src/app/greet-action.ts 20 | "use server"; 21 | 22 | const inputSchema = z.object({ 23 | name: z.string(), 24 | }); 25 | 26 | export const greetUser = actionClient 27 | .inputSchema(inputSchema) 28 | .action(async ({ parsedInput: { name } }) => { 29 | return { message: `Hello ${name}!` }; 30 | }); 31 | ``` 32 | 33 | 2. In your Client Component, you can use it like this: 34 | 35 | ```tsx title=src/app/greet.tsx 36 | "use client"; 37 | 38 | import { useAction } from "next-safe-action/hooks"; 39 | import { greetUser } from "@/app/greet-action"; 40 | 41 | export default function Greet() { 42 | const [name, setName] = useState(""); 43 | // highlight-next-line 44 | const { execute, result } = useAction(greetUser); 45 | 46 | return ( 47 |
48 | setName(e.target.value)} /> 49 | 56 | {result.data?.message ?

{result.data.message}

: null} 57 |
58 | ); 59 | } 60 | ``` 61 | 62 | As you can see, here we display a greet message after the action is performed, if it succeeds. 63 | 64 | ### `useAction()` arguments 65 | 66 | - `safeActionFn`: the safe action that will be called via `execute()` or `executeAsync()` functions. 67 | - `utils?`: object with [callbacks](/docs/execute-actions/hooks/hook-callbacks). 68 | 69 | ### `useAction()` return object 70 | 71 | - `execute()`: an action caller with no return. Input is the same as the safe action you passed to the hook. 72 | - `executeAsync()`: an action caller that returns a promise with the return value of the safe action. Input is the same as the safe action you passed to the hook. 73 | - `input`: the input passed to the `execute()` or `executeAsync()` function. 74 | - `result`: result of the action after its execution. 75 | - `reset()`: programmatically reset execution state (`input`, `status` and `result`). 76 | - `status`: string that represents the current action status. 77 | - `isIdle`: true if the action status is `idle`. 78 | - `isTransitioning`: true if the transition status from the `useTransition` hook used under the hood is `true`. 79 | - `isExecuting`: true if the action status is `executing`. 80 | - `isPending`: true if the action status is `executing` or `transitioning`. 81 | - `hasSucceeded`: true if the action status is `hasSucceeded`. 82 | - `hasErrored`: true if the action status is `hasErrored`. 83 | - `hasNavigated`: true if a `next/navigation` function was called inside the action. 84 | 85 | Explore a working example [here](). 86 | -------------------------------------------------------------------------------- /website/docs/execute-actions/hooks/usestateaction.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: useStateAction() [DEPRECATED] 3 | sidebar_position: 3 4 | description: Learn how to use the useStateAction hook. 5 | --- 6 | 7 | # ~~`useStateAction()`~~ 8 | 9 | :::warning deprecation notice 10 | The `useStateAction()` hook is deprecated since version 8. Directly use the [`useActionState()`](https://react.dev/reference/react/useActionState) hook from React instead. 11 | ::: 12 | 13 | ### `useStateAction()` documentation 14 | 15 | You can access the documentation for the deprecated `useStateAction()` hook in the [v7 docs](https://v7.next-safe-action.dev/docs/execute-actions/hooks/usestateaction). 16 | 17 | ### From v8 onwards 18 | 19 | The `useStateAction()` hook has been deprecated in favor of the [`useActionState()`](https://react.dev/reference/react/useActionState) hook from React, which was used anyway under the hood. This is because the `useStateAction()` hook, while adding useful features, prevented progressive enhancement from working, since it wrapped the `useActionState()` hook with additional functionality that only worked with JavaScript enabled. 20 | 21 | Note that you can also use "stateless" actions with forms, as described in [this section](/docs/recipes/form-actions#stateless-form-actions). 22 | 23 | ### Example 24 | 25 | Take a look at [this section](/docs/recipes/form-actions#stateful-form-actions) of the documentation for an example of how to use the `useActionState()` hook to create a stateful action. -------------------------------------------------------------------------------- /website/docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | description: Getting started with next-safe-action version 7. 4 | --- 5 | 6 | # Getting started 7 | 8 | :::info Requirements 9 | 10 | - Next.js >= 14 11 | - React >= 18.2.0 12 | - TypeScript >= 5 13 | - A validation library supported by [Standard Schema](https://github.com/standard-schema/standard-schema) 14 | ::: 15 | 16 | **next-safe-action** provides a typesafe Server Actions implementation for Next.js App Router applications. 17 | 18 | ## Installation 19 | 20 | ```bash npm2yarn 21 | npm i next-safe-action 22 | ``` 23 | 24 | ## Usage 25 | 26 | ### 1. Instantiate a new client 27 | 28 | You can create a new client with the following code: 29 | 30 | ```typescript title="src/lib/safe-action.ts" 31 | import { createSafeActionClient } from "next-safe-action"; 32 | 33 | export const actionClient = createSafeActionClient(); 34 | ``` 35 | 36 | This is a basic client, without any options or middleware functions. If you want to explore the full set of options, check out the [create the client](/docs/define-actions/create-the-client) section. 37 | 38 | ### 2. Define a new action 39 | 40 | This is how a safe action is created. Providing a validation input schema to the function via [`inputSchema()`](/docs/define-actions/instance-methods#inputschema), we're sure that data that comes in is type safe and validated. 41 | The [`action()`](/docs/define-actions/instance-methods#action--stateaction) method lets you define what happens on the server when the action is called from client, via an async function that receives the parsed input and context as arguments. In short, this is your _server code_. **It never runs on the client. 42 | 43 | In this documentation, we'll use the Zod library to define our validation logic, but feel free to use any other library that implements the [Standard Schema](https://github.com/standard-schema/standard-schema) specification. 44 | 45 | ```typescript title="src/app/login-action.ts" 46 | "use server"; // don't forget to add this! 47 | 48 | import { z } from "zod"; 49 | import { returnValidationErrors } from "next-safe-action"; 50 | import { actionClient } from "@/lib/safe-action"; 51 | 52 | // This schema is used to validate input from client. 53 | const inputSchema = z.object({ 54 | username: z.string().min(3).max(10), 55 | password: z.string().min(8).max(100), 56 | }); 57 | 58 | export const loginUser = actionClient 59 | .inputSchema(inputSchema) 60 | .action(async ({ parsedInput: { username, password } }) => { 61 | if (username === "johndoe" && password === "123456") { 62 | return { 63 | success: "Successfully logged in", 64 | }; 65 | } 66 | 67 | return returnValidationErrors(inputSchema, { _errors: ["Incorrect credentials"] }); 68 | }); 69 | ``` 70 | `action` returns a function that can be called from the client. 71 | 72 | ### 3. Import and execute the action 73 | 74 | In this example, we're **directly** calling the Server Action from a Client Component: 75 | 76 | ```tsx title="src/app/login.tsx" 77 | "use client"; // this is a Client Component 78 | 79 | import { loginUser } from "./login-action"; 80 | 81 | export default function Login() { 82 | return ( 83 | 102 | ); 103 | } 104 | ``` 105 | 106 | You also can execute Server Actions with hooks, which are a more powerful way to handle mutations. For more information about these, check out the [`useAction`](/docs/execute-actions/hooks/useaction) and [`useOptimisticAction`](/docs/execute-actions/hooks/useoptimisticaction) hooks sections. -------------------------------------------------------------------------------- /website/docs/integrations/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Integrations", 3 | "position": 5 4 | } 5 | -------------------------------------------------------------------------------- /website/docs/integrations/react-hook-form.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | description: Learn how to integrate next-safe-action with React Hook Form. 4 | --- 5 | 6 | # React Hook Form 7 | 8 | next-safe-action works great in combo with [React Hook Form](https://react-hook-form.com/). 9 | 10 | By using the react-hook-form adapter, you'll get first-class integration and complete customization for advanced use cases. First, install the following dependencies: 11 | 12 | ```bash npm2yarn 13 | npm i next-safe-action react-hook-form @hookform/resolvers @next-safe-action/adapter-react-hook-form 14 | ``` 15 | 16 | Then, take a look at the project page on GitHub to learn how to use it: [@next-safe-action/adapter-react-hook-form](https://github.com/next-safe-action/adapter-react-hook-form). -------------------------------------------------------------------------------- /website/docs/migrations/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Migrations", 3 | "position": 7 4 | } 5 | -------------------------------------------------------------------------------- /website/docs/migrations/v3-to-v4.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | description: Learn how to migrate from next-safe-action version 3 to version 4. 4 | sidebar_label: v3 to v4 5 | --- 6 | 7 | # Migration from v3 to v4 8 | 9 | Version 4.x.x of `next-safe-action` introduced many improvements, some fixes, and some breaking changes. 10 | 11 | This guide will help you migrate from v3 to v4, hopefully without too much trouble. 12 | 13 | :::note 14 | You can continue to use version 3 of the library if you want to. There are no security implications, since version 4 introduced some new features and changed some functions and properties names. No security patches were committed to v4, at least for the time being, so v3 is currently still safe to use. You'll not get new features in v3, though. 15 | ::: 16 | 17 | ## BREAKING CHANGES 18 | 19 | ### Safe action client 20 | 21 | - `buildContext()` function is now called `middleware()`, and it can still return a context object. 22 | - `serverErrorLogFunction()` function is now called `handleServerErrorLog()`. 23 | 24 | ### Hooks 25 | 26 | - `res` object is now called `result`. 27 | - Action status before was reported through returned `hasExecuted`, `isExecuting`, `hasSucceeded` and `hasErrored` properties. Now there's a single property of type string called `status` that contains the current action status, and it can be `"idle"`, `"executing"`, `"hasSucceeded"` or `"hasErrored"`. 28 | - Reorganized callbacks arguments for `onSuccess` and `onError`: 29 | - from `onSuccess(data, reset, input)` to `onSuccess(data, input, reset)` 30 | - from `onError(error, reset, input)` to `onError(error, input, reset)` 31 | - `useOptimisticAction` just required a safe action and an initial optimistic state before. Now it requires a `reducer` function too, that determines the behavior of the optimistic state update when the `execute` function is called. Also, now only one input argument is required by `execute`, instead of two. The same input passed to the actual safe action is now passed to the `reducer` function too, as the second argument (`input`). More information about this hook can be found [here](/docs/execute-actions/hooks/useoptimisticaction). 32 | 33 | ### Types 34 | 35 | - `ActionDefinition` is now called `ServerCode`. 36 | - `HookRes` is now called `HookResult`. 37 | - `ClientCaller` is now called `SafeAction`. 38 | 39 | ## New features 40 | 41 | ### Hooks 42 | 43 | - Added optional `onSettled` callback for `useAction` and `useOptimisticAction` hooks. It gets executed if the action succeeds or fails, after `onSuccess` and `onError`. 44 | 45 | ## Fixes 46 | 47 | - Fixed an issue with Zod input validation parsing. Before, if an async `superRefine()` was used when defining the schema, the validation would fail, resulting in a `serverError` response for the client. Now the validation is done through `safeParseAsync()`, so the problem is gone. 48 | 49 | ## Misc 50 | 51 | ### Safe action client 52 | 53 | - Now `Context` returned by `middleware()` (previously called `buildContext()` in v3) is not required to be an object anymore, it can be of any type. 54 | 55 | ### Hooks 56 | 57 | - Before, you had to return an object from actions you wanted to execute via `useOptimisticAction` hook. Now, with the new exposed `reducer` function (see above), you can return anything you want from action server code body. 58 | -------------------------------------------------------------------------------- /website/docs/migrations/v4-to-v5.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 2 3 | description: Learn how to migrate from next-safe-action version 4 to version 5. 4 | sidebar_label: v4 to v5 5 | --- 6 | 7 | # Migration from v4 to v5 8 | 9 | Version 5.x.x of `next-safe-action` is required for Next.js >= 14 applications. 10 | 11 | :::note 12 | You can continue to use version 4 of the library, compatible with Next.js 13: `npm i next-safe-action@4` 13 | ::: 14 | 15 | ## BREAKING CHANGES 16 | 17 | Server Actions are now stable, so there's no need to enable them as an experimental feature in your Next.js config file anymore: 18 | 19 | ```diff title=next.config.js 20 | module.exports = { 21 | - experimental: { 22 | - serverActions: true 23 | - } 24 | } 25 | ``` 26 | 27 | ### Internal changes (hooks) 28 | 29 | React now exports `useOptimistic` hook, instead of the previous `experimental_useOptimistic`. This is why a new major version of `next-safe-action` is required for Next.js >= 14 apps. 30 | -------------------------------------------------------------------------------- /website/docs/migrations/v5-to-v6.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 3 3 | description: Learn how to migrate from next-safe-action version 5 to version 6. 4 | sidebar_label: v5 to v6 5 | --- 6 | 7 | # Migration from v5 to v6 8 | 9 | ## What's new? 10 | 11 | With next-safe-action version 6, you can now use a wide range of validation libraries, even multiple and custom ones at the same time, thanks to the great [TypeSchema](https://typeschema.com/) library. You can find supported libraries [here](https://typeschema.com/#coverage). 12 | 13 | Existing code will not be affected, since Zod is supported by TypeSchema. However, now you can for example define a new safe action using [Yup](https://github.com/jquense/yup) or [Valibot](https://valibot.dev/), while still keeping existing actions with Zod validation, and everything will be handled internally by next-safe-action, thanks to the TypeSchema abstractions. 14 | 15 | ## BREAKING CHANGES 16 | 17 | ### Action result object 18 | 19 | - Property `validationError` is now called `validationErrors`. 20 | 21 | ### Safe action client 22 | 23 | - `handleReturnedServerError()` function now directly returns the server error message as a `string`, instead of a `{ serverError: string }` object. 24 | 25 | ### Hooks 26 | 27 | Hooks are now exported from `next-safe-action/hooks` instead of `next-safe-action/hook`. 28 | 29 | ### Types 30 | 31 | - `ServerCode` is now called `ServerCodeFn`. 32 | 33 | ## Misc changes 34 | 35 | ### Types 36 | 37 | - Exported new `SafeClientOpts` type, which represents the options for the safe action client, used internally by `createSafeActionClient()` function. 38 | -------------------------------------------------------------------------------- /website/docs/recipes/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Recipes", 3 | "position": 4 4 | } 5 | -------------------------------------------------------------------------------- /website/docs/recipes/i18n.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 10 3 | description: Learn how to use next-safe-action with a i18n solution. 4 | --- 5 | 6 | # i18n 7 | 8 | If you're using a i18n solution, there's a high probability that you'll need to await the translations and then pass them to schemas.\ 9 | next-safe-action allows you to do that by passing an async function to the [`schema`](/docs/define-actions/instance-methods#schema) method that returns a promise with the schema.\ 10 | The setup is pretty simple: 11 | 12 | ```typescript 13 | "use server"; 14 | 15 | import { actionClient } from "@/lib/safe-action"; 16 | import { z } from "zod"; 17 | import { getTranslations } from "my-i18n-lib"; 18 | 19 | // highlight-start 20 | async function getSchema() { 21 | // This is an example of a i18n setup. 22 | const t = await getTranslations(); 23 | return mySchema(t); // this is the schema that will be used to validate and parse the input 24 | } 25 | // highlight-end 26 | 27 | export const myAction = actionClient 28 | // highlight-next-line 29 | .inputSchema(getSchema) 30 | .action(async ({ parsedInput }) => { 31 | // Do something useful here... 32 | }); 33 | ``` -------------------------------------------------------------------------------- /website/docs/recipes/playground.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 1 3 | description: Explore a basic implementation of next-safe-action. 4 | --- 5 | 6 | # Playground 7 | 8 | You can find a basic working implementation [here](https://next-safe-action-playground.vercel.app/). 9 | 10 | If you want to explore the project, you can check out its source code [here](https://github.com/TheEdoRan/next-safe-action/tree/main/apps/playground). 11 | -------------------------------------------------------------------------------- /website/docs/recipes/upload-files.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 9 3 | description: Learn how to upload a file using next-safe-action. 4 | --- 5 | 6 | # Upload files 7 | 8 | Server Actions also allow you to upload files by using forms and inputs with type `file`. 9 | 10 | :::note 11 | 1. File uploads only works with Node.js >= 20. Node.js 18 is not supported. 12 | 2. If you exceed 1 MB in size, by default, Next.js throws an error on the server informing that the file is too big. You can customize the max size by setting the [`bodySizeLimit`](https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit) option in `next.config.js`. 13 | ::: 14 | 15 | Since you **must** use `FormData` to upload files, here we use Zod and the [`zod-form-data`](https://www.npmjs.com/package/zod-form-data) library to validate and parse the input. 16 | 17 | ```typescript title="file-upload-action.ts" 18 | "use server"; 19 | 20 | import { action } from "@/lib/safe-action"; 21 | // highlight-next-line 22 | import { zfd } from "zod-form-data"; 23 | 24 | // highlight-start 25 | const inputSchema = zfd.formData({ 26 | image: zfd.file(), 27 | }); 28 | // highlight-end 29 | 30 | export const fileUploadAction = action 31 | .inputSchema(inputSchema) 32 | .action(async ({ parsedInput }) => { 33 | await new Promise((res) => setTimeout(res, 1000)); 34 | 35 | // Do something useful with the file. 36 | // highlight-next-line 37 | console.log("fileUploadAction ->", parsedInput); 38 | 39 | return { 40 | ok: true, 41 | }; 42 | }); 43 | ``` 44 | 45 | ```tsx title="file-upload.tsx" 46 | "use client"; 47 | 48 | import { useAction } from "next-safe-action/hooks"; 49 | import { fileUploadAction } from "./file-upload-action"; 50 | 51 | export default function FileUploadPage() { 52 | // highlight-next-line 53 | const { execute } = useAction(fileUploadAction); 54 | 55 | return ( 56 | // highlight-start 57 |
58 | 64 | // highlight-end 65 | 66 |
67 | ); 68 | } 69 | ``` -------------------------------------------------------------------------------- /website/docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | --- 2 | sidebar_position: 8 3 | description: Troubleshoot common issues with next-safe-action. 4 | --- 5 | 6 | # Troubleshooting 7 | 8 | ## TypeSchema issues (pre v7.2.0) 9 | 10 | **NOTE**: next-safe-action used TypeSchema up to version 7.1.3. If you use version 7.2.0 or later, these issues are fixed. 11 | 12 | ### `Schema` and `parsedInput` are typed `any` (broken types) and build issues 13 | 14 | At this time, TypeSchema (the library used under the hood up to v7.1.3 for supporting multiple validation libraries) doesn't work with TypeScript >= 5.5; the resulting types for inputs and schemas are `any`, so type inference is broken. 15 | 16 | If you're in this situation, please upgrade to v7.2.0 or later to fix the issue. 17 | 18 | ### TypeSchema issues with Edge Runtime 19 | 20 | TypeSchema enables support for many validation libraries, via adapters. However, since it relies on the dynamic import feature, it won't work with the Edge Runtime. Please upgrade to v7.2.0 or later to fix the issue. 21 | 22 | ## TypeScript error in monorepo 23 | 24 | If you use next-safe-action in a monorepo, you'll likely experience this error: 25 | 26 | ``` 27 | Type error: The inferred type of 'action' cannot be named without a reference to '...'. This is likely not portable. A type annotation is necessary. 28 | ``` 29 | 30 | You can set this option in your `tsconfig.json` to remove the error: 31 | 32 | ```json title="tsconfig.json" 33 | { 34 | "compilerOptions": { 35 | // highlight-next-line 36 | "baseUrl": "." 37 | } 38 | } 39 | ``` 40 | 41 | Find more information about this in [this issue](https://github.com/TheEdoRan/next-safe-action/issues/64) on GitHub. -------------------------------------------------------------------------------- /website/docs/types/_category_.json: -------------------------------------------------------------------------------- 1 | { 2 | "label": "Types", 3 | "position": 6 4 | } 5 | -------------------------------------------------------------------------------- /website/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | import { themes } from "prism-react-renderer"; 2 | 3 | /** @type {import('@docusaurus/types').Config} */ 4 | export default { 5 | title: "next-safe-action", 6 | tagline: "Type safe Server Actions in your Next.js project", 7 | favicon: "img/favicon.ico", 8 | 9 | // Set the production url of your site here 10 | url: "https://next-safe-action.dev", 11 | // Set the // pathname under which your site is served 12 | // For GitHub pages deployment, it is often '//' 13 | baseUrl: "/", 14 | 15 | // GitHub pages deployment config. 16 | // If you aren't using GitHub pages, you don't need these. 17 | organizationName: "TheEdoRan", // Usually your GitHub org/user name. 18 | projectName: "next-safe-action", // Usually your repo name. 19 | 20 | onBrokenLinks: "throw", 21 | onBrokenMarkdownLinks: "warn", 22 | onDuplicateRoutes: "throw", 23 | onBrokenAnchors: "throw", 24 | 25 | scripts: [ 26 | { 27 | "src": "https://plausible.theedoran.xyz/js/script.js", 28 | "async": true, 29 | "defer": true, 30 | "data-domain": "next-safe-action.dev", 31 | }, 32 | ], 33 | headTags: [ 34 | { 35 | tagName: "link", 36 | attributes: { 37 | rel: "preconnect", 38 | href: "https://fonts.googleapis.com", 39 | }, 40 | }, 41 | { 42 | tagName: "link", 43 | attributes: { 44 | rel: "preconnect", 45 | href: "https://fonts.gstatic.com", 46 | crossorigin: "anonymous", 47 | }, 48 | }, 49 | { 50 | tagName: "link", 51 | attributes: { 52 | rel: "stylesheet", 53 | href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap", 54 | }, 55 | }, 56 | ], 57 | 58 | // Even if you don't use internalization, you can use this field to set useful 59 | // metadata like html lang. For example, if your site is Chinese, you may want 60 | // to replace "en" with "zh-Hans". 61 | i18n: { 62 | defaultLocale: "en", 63 | locales: ["en"], 64 | }, 65 | presets: [ 66 | [ 67 | "classic", 68 | /** @type {import('@docusaurus/preset-classic').Options} */ 69 | { 70 | docs: { 71 | sidebarPath: require.resolve("./sidebars.js"), 72 | // Please change this to your repo. 73 | // Remove this to remove the "edit this page" links. 74 | editUrl: "https://github.com/TheEdoRan/next-safe-action/tree/main/website", 75 | remarkPlugins: [[require("@docusaurus/remark-plugin-npm2yarn"), { sync: true }]], 76 | }, 77 | blog: false, 78 | theme: { 79 | customCss: require.resolve("./src/css/custom.css"), 80 | }, 81 | // sitemap: { 82 | // lastmod: "date", 83 | // changefreq: "weekly", 84 | // priority: 0.8, 85 | // filename: "sitemap.xml", 86 | // createSitemapItems: async (params) => { 87 | // const { defaultCreateSitemapItems, ...rest } = params; 88 | // const items = await defaultCreateSitemapItems(rest); 89 | // return items 90 | // ); 91 | // }, 92 | }, 93 | ], 94 | ], 95 | 96 | /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ 97 | themeConfig: { 98 | colorMode: { 99 | defaultMode: "light", 100 | respectPrefersColorScheme: true, 101 | }, 102 | // Replace with your project's social card 103 | image: "img/social-card.png", 104 | algolia: { 105 | appId: "I6TZS9IBSZ", 106 | apiKey: "87b638e133658cdec7cc633e6c4986c3", 107 | indexName: "next-safe-action", 108 | }, 109 | navbar: { 110 | title: "next-safe-action", 111 | logo: { 112 | alt: "next-safe-action", 113 | src: "img/logo-light-mode.svg", 114 | srcDark: "img/logo-dark-mode.svg", 115 | }, 116 | items: [ 117 | { 118 | type: "docSidebar", 119 | sidebarId: "docsSidebar", 120 | position: "left", 121 | label: "Docs", 122 | }, 123 | { 124 | "href": "https://github.com/TheEdoRan/next-safe-action", 125 | "position": "right", 126 | "className": "header-github-link", 127 | "aria-label": "next-safe-action's GitHub page", 128 | }, 129 | ], 130 | }, 131 | announcementBar: { 132 | id: "next-safe-action-v8", 133 | content: 134 | "next-safe-action v8 is now available! Check out the migration guide to learn how to update your code for v8.", 135 | backgroundColor: "#2B2B2B", 136 | textColor: "#fff", 137 | isCloseable: true, 138 | }, 139 | prism: { 140 | additionalLanguages: ["typescript"], 141 | theme: themes.vsLight, 142 | darkTheme: themes.oceanicNext, 143 | }, 144 | }, 145 | }; 146 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "start": "docusaurus start", 8 | "build": "docusaurus build", 9 | "swizzle": "docusaurus swizzle", 10 | "deploy": "docusaurus deploy", 11 | "clear": "docusaurus clear", 12 | "serve": "docusaurus serve", 13 | "write-translations": "docusaurus write-translations", 14 | "write-heading-ids": "docusaurus write-heading-ids", 15 | "typecheck": "tsc" 16 | }, 17 | "dependencies": { 18 | "@docusaurus/core": "3.8.0", 19 | "@docusaurus/preset-classic": "3.8.0", 20 | "@docusaurus/remark-plugin-npm2yarn": "^3.8.0", 21 | "@mdx-js/react": "^3.1.0", 22 | "acorn": "8.14.1", 23 | "clsx": "^2.1.1", 24 | "embla-carousel-auto-scroll": "^8.6.0", 25 | "embla-carousel-react": "^8.6.0", 26 | "lucide-react": "^0.487.0", 27 | "prism-react-renderer": "^2.4.1", 28 | "react": "^19", 29 | "react-dom": "^19", 30 | "react-syntax-highlighter": "^15.6.1" 31 | }, 32 | "devDependencies": { 33 | "@docusaurus/module-type-aliases": "3.8.0", 34 | "@docusaurus/tsconfig": "^3.8.0", 35 | "@docusaurus/types": "^3.8.0", 36 | "@types/react": "^19", 37 | "@types/react-syntax-highlighter": "^15.5.13", 38 | "autoprefixer": "^10.4.21", 39 | "postcss": "^8.5.3", 40 | "postcss-nested": "^7.0.2", 41 | "tailwindcss": "^3", 42 | "tailwindcss-bg-patterns": "^0.3.0", 43 | "typescript": "^5.8.2" 44 | }, 45 | "browserslist": { 46 | "production": [ 47 | ">0.5%", 48 | "not dead", 49 | "not op_mini all" 50 | ], 51 | "development": [ 52 | "last 1 chrome version", 53 | "last 1 firefox version", 54 | "last 1 safari version" 55 | ] 56 | }, 57 | "engines": { 58 | "node": ">=18.0" 59 | }, 60 | "pnpm": { 61 | "onlyBuiltDependencies": [ 62 | "core-js", 63 | "core-js-pure" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /website/pnpm-workspace.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheEdoRan/next-safe-action/e21c47eef91bee471018d2efda13d7dcf9b927f3/website/pnpm-workspace.yaml -------------------------------------------------------------------------------- /website/postcss.config.js: -------------------------------------------------------------------------------- 1 | // postcss.config.js 2 | module.exports = { 3 | plugins: { 4 | "postcss-import": {}, 5 | "tailwindcss/nesting": "postcss-nested", 6 | "tailwindcss": {}, 7 | "autoprefixer": {}, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /website/sidebars.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Creating a sidebar enables you to: 3 | - create an ordered group of docs 4 | - render a sidebar for each doc of that group 5 | - provide next/previous navigation 6 | 7 | The sidebars can be generated from the filesystem, or explicitly defined here. 8 | 9 | Create as many sidebars as you want. 10 | */ 11 | 12 | // @ts-check 13 | 14 | /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ 15 | const sidebars = { 16 | // By default, Docusaurus generates a sidebar from the docs folder structure 17 | docsSidebar: [{ type: "autogenerated", dirName: "." }], 18 | 19 | // But you can create a sidebar manually 20 | /* 21 | tutorialSidebar: [ 22 | 'intro', 23 | 'hello', 24 | { 25 | type: 'category', 26 | label: 'Tutorial', 27 | items: ['tutorial-basics/create-a-document'], 28 | }, 29 | ], 30 | */ 31 | }; 32 | 33 | module.exports = sidebars; 34 | -------------------------------------------------------------------------------- /website/src/components/landing/features.tsx: -------------------------------------------------------------------------------- 1 | import { Check } from "lucide-react"; 2 | 3 | const features: { title: string; description: string; icon?: string }[] = [ 4 | { 5 | title: "Pretty simple", 6 | description: 7 | "No need to overcomplicate things.
next-safe-action API is pretty simple, designed for the best possible DX.", 8 | }, 9 | { 10 | title: "End-to-end type safe", 11 | description: "With next-safe-action you get full type safety between server and client code.", 12 | }, 13 | { 14 | title: "Input/output validation", 15 | description: 16 | 'next-safe-action supports any validation library supported by Standard Schema. You can use Zod, Valibot, ArkType, and many more!', 17 | }, 18 | { 19 | title: "Powerful middleware system", 20 | description: "Manage authorization, log and halt execution, and much more with a composable middleware system.", 21 | }, 22 | { 23 | title: "Advanced error handling", 24 | description: 25 | "Decide how to return execution and validation errors to the client and how to log them on the server.", 26 | }, 27 | { 28 | title: "Form Actions support", 29 | description: "next-safe-action supports Form Actions out of the box, with stateful and stateless actions.", 30 | }, 31 | { 32 | title: "Optimistic updates", 33 | description: 34 | "Need to update UI immediately without waiting for server response? You can do it with the powerful useOptimisticAction hook.", 35 | }, 36 | { 37 | title: "Integration with third party libraries", 38 | description: 39 | "next-safe-action is designed to be extensible. You can easily integrate it with third party libraries, like react-hook-form.", 40 | }, 41 | ]; 42 | 43 | export function Features() { 44 | return ( 45 |
46 |
47 |
48 |
49 |

Why choose next-safe-action?

50 |

51 | A type-safe approach to handling Server Actions in your Next.js applications 52 |

53 |
54 | 55 |
56 | {features.map((feature, index) => ( 57 |
58 |
59 |
60 |
61 | 62 |
63 |

{feature.title}

64 |
68 |
69 |
70 | ))} 71 |
72 |
73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /website/src/components/landing/getting-started.tsx: -------------------------------------------------------------------------------- 1 | export function GettingStarted() { 2 | return ( 3 |
4 | {/* Bubblegum background elements */} 5 |
6 |
7 |
8 |
9 | 10 |
11 |
12 |

13 | Ready to get started? 14 |

15 |

16 | Explore the documentation to learn how to use next-safe-action in your Next.js projects. Whether you're a 17 | beginner or already experienced with it, we've got you covered with comprehensive guides and examples. 18 |

19 | 35 |
36 |
37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /website/src/components/landing/github-button.tsx: -------------------------------------------------------------------------------- 1 | import { StarIcon } from "lucide-react"; 2 | import { useEffect, useState } from "react"; 3 | 4 | function useFetchStarsCount() { 5 | const [starsCount, setStarsCount] = useState(undefined); 6 | 7 | useEffect(() => { 8 | fetch("https://api.github.com/repos/TheEdoRan/next-safe-action") 9 | .then((res) => 10 | res.json().then((data) => { 11 | if (typeof data.stargazers_count === "number") { 12 | setStarsCount(data.stargazers_count); 13 | } 14 | }) 15 | ) 16 | .catch(console.error); 17 | }, []); 18 | 19 | return { starsCount }; 20 | } 21 | 22 | export function GitHubButton() { 23 | const { starsCount } = useFetchStarsCount(); 24 | 25 | return ( 26 | 32 | 33 | {starsCount ? Intl.NumberFormat("en", { notation: "compact" }).format(starsCount) : "..."} GitHub 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /website/src/components/landing/hero-example.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 5 | import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; 6 | 7 | const tabs = [ 8 | { id: "client", label: "safe-action.ts" }, 9 | { id: "action", label: "greet-action.ts" }, 10 | { id: "component", label: "greet.tsx" }, 11 | ] as const; 12 | 13 | type Tab = (typeof tabs)[number]["id"]; 14 | 15 | export function HeroExample() { 16 | const [activeTab, setActiveTab] = useState("action"); 17 | 18 | const codeContent = { 19 | client: `import { createSafeActionClient } from "next-safe-action"; 20 | 21 | // Create the client with default options. 22 | export const actionClient = createSafeActionClient();`, 23 | action: `"use server"; 24 | 25 | import { z } from "zod"; 26 | import { actionClient } from "./safe-action"; 27 | 28 | const inputSchema = z.object({ 29 | name: z.string().min(1), 30 | }); 31 | 32 | export const greetAction = actionClient 33 | .inputSchema(inputSchema) 34 | .action(async ({ parsedInput: { name } }) => { 35 | return { 36 | message: \`Hello, \${name}!\`, 37 | }; 38 | });`, 39 | component: `"use client"; 40 | 41 | import { useAction } from "next-safe-action/hooks"; 42 | import { greetAction } from "./greet-action"; 43 | 44 | export function Greet() { 45 | const { execute, result, reset } = useAction(greetAction); 46 | 47 | return ( 48 |
49 | 52 | 53 |
54 | ); 55 | }`, 56 | }; 57 | 58 | return ( 59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | {tabs.map((tab) => ( 71 | 81 | ))} 82 |
83 |
84 |
85 |
86 | 96 | {codeContent[activeTab]} 97 | 98 |
99 |
100 |
101 | ); 102 | } 103 | -------------------------------------------------------------------------------- /website/src/components/landing/hero.tsx: -------------------------------------------------------------------------------- 1 | import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; 2 | import { GitHubButton } from "./github-button"; 3 | import { HeroExample } from "./hero-example"; 4 | import { InstallBox } from "./install-box"; 5 | 6 | export function Hero() { 7 | const { siteConfig } = useDocusaurusContext(); 8 | 9 | return ( 10 |
11 |
12 |
13 |
14 |
15 |
16 |

17 | {siteConfig.tagline} 18 |

19 |

20 | next-safe-action handles your Next.js app mutations type safety, input/output validation, server 21 | errors and even more! 22 |

23 |
24 | 25 | 26 | 27 | 36 |
37 | 38 | 39 |
40 |
41 |
42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /website/src/components/landing/index.tsx: -------------------------------------------------------------------------------- 1 | import { Features } from "./features"; 2 | import { GettingStarted } from "./getting-started"; 3 | import { Hero } from "./hero"; 4 | import { Playground } from "./playground"; 5 | import { Sponsors } from "./sponsors"; 6 | import { Testimonials } from "./testimonials"; 7 | 8 | export function Landing() { 9 | return ( 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /website/src/components/landing/install-box.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const packageManagers = ["pnpm", "npm", "yarn"] as const; 4 | type PackageManager = (typeof packageManagers)[number]; 5 | 6 | const getPmInstall = (pm: PackageManager) => { 7 | switch (pm) { 8 | case "pnpm": 9 | return "pnpm add"; 10 | case "npm": 11 | return "npm install"; 12 | case "yarn": 13 | return "yarn add"; 14 | default: 15 | return ""; 16 | } 17 | }; 18 | 19 | const libName = "next-safe-action"; 20 | 21 | type Props = { 22 | className?: string; 23 | }; 24 | 25 | export function InstallBox({ className }: Props) { 26 | const [packageManager, setPackageManager] = useState("pnpm"); 27 | const [copied, setCopied] = useState(false); 28 | 29 | const copyToClipboard = () => { 30 | navigator.clipboard.writeText(`${getPmInstall(packageManager)} ${libName}`); 31 | setCopied(true); 32 | setTimeout(() => setCopied(false), 2000); 33 | }; 34 | 35 | return ( 36 |
37 |
38 | {packageManagers.map((pm) => ( 39 | 53 | ))} 54 |
55 | 56 |
57 |
58 | $ 59 |
60 | {getPmInstall(packageManager)} 61 | 62 | {libName} 63 |
64 |
65 | 103 |
104 |
105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /website/src/components/landing/playground.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useRef, useState } from "react"; 4 | 5 | export function Playground() { 6 | const [isVisible, setIsVisible] = useState(false); 7 | const containerRef = useRef(null); 8 | 9 | useEffect(() => { 10 | const observer = new IntersectionObserver( 11 | (entries) => { 12 | if (entries[0].isIntersecting) { 13 | setIsVisible(true); 14 | observer.disconnect(); 15 | } 16 | }, 17 | { threshold: 0.1 } 18 | ); 19 | 20 | if (containerRef.current) { 21 | observer.observe(containerRef.current); 22 | } 23 | 24 | return () => { 25 | observer.disconnect(); 26 | }; 27 | }, []); 28 | 29 | return ( 30 |
31 |
32 |
33 |

Try it out

34 |

35 | See next-safe-action in action 36 |

37 |
38 | {isVisible && ( 39 |