├── .env.example ├── .gitignore ├── LICENSE.md ├── README.md ├── app.vue ├── components ├── ApplicationLogo.vue ├── AuthCard.vue ├── AuthSessionStatus.vue ├── Button.vue ├── Dropdown │ ├── Button.vue │ ├── Dropdown.vue │ └── Link.vue ├── FlashMessages.vue ├── Input.vue ├── Label.vue └── Navigation │ ├── Link.vue │ ├── Navigation.vue │ ├── ResponsiveButton.vue │ └── ResponsiveLink.vue ├── composables ├── useAuth.ts ├── useLarafetch.ts └── useSubmit.ts ├── layouts ├── app-layout.vue └── default.vue ├── middleware ├── auth.ts ├── guest.ts ├── unverified.ts └── verified.ts ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── pages ├── [...slug].vue ├── dashboard.vue ├── forgot-password.vue ├── index.vue ├── login.vue ├── password-reset │ └── [token].vue ├── register.vue └── verify-email.vue ├── plugins ├── auth.ts └── error-handler.ts ├── tailwind.config.js ├── tsconfig.json └── utils └── $larafetch.ts /.env.example: -------------------------------------------------------------------------------- 1 | NUXT_PUBLIC_BACKEND_URL=http://localhost:8000 2 | NUXT_PUBLIC_FRONTEND_URL=http://localhost:3000 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Amr Noman 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Breeze - Nuxt3 Edition 🏝️ 2 | 3 | ## Introduction 4 | 5 | Based on the work made at [breeze-next](https://github.com/laravel/breeze-next) 6 | 7 | This repository is an implementation of the [Laravel Breeze](https://laravel.com/docs/starter-kits) application / authentication starter kit frontend in [Nuxt3](https://v3.nuxtjs.org/). All of the authentication boilerplate is already written for you - powered by [Laravel Sanctum](https://laravel.com/docs/sanctum), allowing you to quickly begin pairing your beautiful Nuxt3 frontend with a powerful Laravel backend. 8 | 9 | ## Installation 10 | 11 | First, create a Nuxt3 compatible Laravel backend by installing Laravel Breeze into a [fresh Laravel application](https://laravel.com/docs/installation) and installing Breeze's API scaffolding: 12 | 13 | ```bash 14 | # Create the Laravel application... 15 | laravel new backend 16 | 17 | cd backend 18 | 19 | # Install Breeze and dependencies... 20 | composer require laravel/breeze --dev 21 | 22 | php artisan breeze:install api 23 | 24 | # Run database migrations 25 | php artisan migrate 26 | ``` 27 | 28 | Next, ensure that your application's `APP_URL` and `FRONTEND_URL` environment variables are set to `http://localhost:8000` and `http://localhost:3000`, respectively. 29 | 30 | Also, when using email verification, you can change the page your users are redirected to by updating `HOME` constant in your `RouteServiceProvider.php` file: 31 | 32 | ```php 33 | class RouteServiceProvider extends ServiceProvider 34 | { 35 | // ... 36 | public const HOME = '/dashboard'; 37 | // ... 38 | } 39 | ``` 40 | 41 | After defining the appropriate environment variables, you may serve the Laravel application using the `serve` Artisan command: 42 | 43 | ```bash 44 | # Serve the application... 45 | php artisan serve 46 | ``` 47 | 48 | Next, clone this repository and install its dependencies with `yarn install` or `npm install`. Then, copy the `.env.example` file to `.env` and supply the URL of your backend and frontend: 49 | 50 | ``` 51 | NUXT_PUBLIC_BACKEND_URL=http://localhost:8000 52 | NUXT_PUBLIC_FRONTEND_URL=http://localhost:3000 53 | ``` 54 | 55 | Finally, run the application via `npm run dev`. The application will be available at `http://localhost:3000`: 56 | 57 | ``` 58 | npm run dev 59 | ``` 60 | 61 | > **Note** 62 | > Currently, we recommend using `localhost` during local development of your backend and frontend to avoid CORS "Same-Origin" issues. 63 | 64 | 65 | **Alternatively**, you can use [concurrently](https://github.com/open-cli-tools/concurrently) to run both servers of Nuxt and Laravel with a single command. 66 | 67 | ```bash 68 | # Install concurrently 69 | npm install --save-dev concurrently 70 | ``` 71 | Then add this script to `package.json` (this assumes your Laravel app lives in `../backend` relative to your Nuxt app): 72 | ```json 73 | "scripts": { 74 | "dev:fullstack": "concurrently --names 'LARAVEL,NUXT' --prefix-colors 'yellow,blue' --kill-others 'cd ../backend/ && php artisan serve' 'nuxi dev'", 75 | }, 76 | ``` 77 | 78 | ## Utilities 79 | 80 | You have the following auto imported utilities in the `utils` directory: 81 | 82 | ### $larafetch 83 | 84 | `$larafetch` is a wrapper around Nuxt's `$fetch` that makes it a breeze to make requests to your Laravel app: 85 | 86 | - Base URL is already set to `NUXT_PUBLIC_BACKEND_URL` value specified in your `.env` file. 87 | - Auto CSRF management. 88 | - Forwards the appropriate headers/cookies when in SSR context. 89 | 90 | > **Note** 91 | > To take advantage of Nuxt3 SSR Hydration when making `GET` requests, you should use the `useLarafetch` composable rather than directly calling `$larafetch`, otherwise your app will make additional unnecessary requests once the page loads in your browser (The same also applies to Nuxt's regular `$fetch` and `useFetch`). 92 | 93 | ## Composables 94 | 95 | ### useAuth 96 | 97 | This Nuxt3 application contains a custom `useAuth` composable, designed to abstract all authentication logic away from your pages. In addition, the composable can be used to access the currently authenticated user: 98 | 99 | ```vue 100 | 103 | 104 | 111 | ``` 112 | 113 | ### useLarafetch 114 | 115 | `useLarafetch` is a wrapper around Nuxt's `useFetch` which uses `$larafetch` instead of `$fetch`: 116 | 117 | ```vue 118 | 121 | 122 | 127 | 128 | ``` 129 | 130 | ### useSubmit 131 | 132 | `useSubmit` is a useful composable to track validation errors and loading state when making `POST` or `PUT` requests: 133 | 134 | ```vue 135 | 149 | 150 | 156 | ``` 157 | 158 | ## Middleware 159 | 160 | You can use any of the provided middlewares in your pages: 161 | 162 | ```vue 163 | 166 | 167 | 170 | ``` 171 | 172 | - `auth` 173 | 174 | Only logged in users can access the page, otherwise redirect to `/login` page. 175 | 176 | - `guest` 177 | 178 | Only non-logged in users can access the page, otherwise redirect to the `/dashboard` page. 179 | 180 | - `verified` 181 | 182 | Only logged in users with verified emails can access the page, otherwise redirect to `/login` page (if not logged in) or `/verify-email` page (if logged in). 183 | 184 | - `unverified` 185 | 186 | Only logged in users with unverified emails can access the page, otherwise redirect to `/login` page (if not logged in) or `/dashboard` page (if logged in). This is used only for the `/verify-email` page. 187 | 188 | ## License 189 | 190 | Laravel Breeze Nuxt3 is open-sourced software licensed under the [MIT license](LICENSE.md). 191 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 16 | -------------------------------------------------------------------------------- /components/ApplicationLogo.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /components/AuthCard.vue: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /components/AuthSessionStatus.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /components/Button.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /components/Dropdown/Button.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 20 | -------------------------------------------------------------------------------- /components/Dropdown/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 53 | -------------------------------------------------------------------------------- /components/Dropdown/Link.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 20 | -------------------------------------------------------------------------------- /components/FlashMessages.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 25 | -------------------------------------------------------------------------------- /components/Input.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 35 | -------------------------------------------------------------------------------- /components/Label.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /components/Navigation/Link.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /components/Navigation/Navigation.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 138 | -------------------------------------------------------------------------------- /components/Navigation/ResponsiveButton.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /components/Navigation/ResponsiveLink.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /composables/useAuth.ts: -------------------------------------------------------------------------------- 1 | export type User = { 2 | name: string; 3 | email?: string; 4 | }; 5 | 6 | export type LoginCredentials = { 7 | email: string; 8 | password: string; 9 | }; 10 | 11 | export type RegisterCredentials = { 12 | name: string; 13 | email: string; 14 | password: string; 15 | password_confirmation: string; 16 | }; 17 | 18 | export type ResetPasswordCredentials = { 19 | email: string; 20 | password: string; 21 | password_confirmation: string; 22 | token: string; 23 | }; 24 | 25 | // Value is initialized in: ~/plugins/auth.ts 26 | export const useUser = () => { 27 | return useState("user", () => undefined); 28 | }; 29 | 30 | export const useAuth = () => { 31 | const router = useRouter(); 32 | 33 | const user = useUser(); 34 | const isLoggedIn = computed(() => !!user.value); 35 | 36 | async function refresh() { 37 | try { 38 | user.value = await fetchCurrentUser(); 39 | } catch { 40 | user.value = null; 41 | } 42 | } 43 | 44 | async function login(credentials: LoginCredentials) { 45 | if (isLoggedIn.value) return; 46 | 47 | await $larafetch("/login", { method: "post", body: credentials }); 48 | await refresh(); 49 | } 50 | 51 | async function register(credentials: RegisterCredentials) { 52 | await $larafetch("/register", { method: "post", body: credentials }); 53 | await refresh(); 54 | } 55 | 56 | async function resendEmailVerification() { 57 | return await $larafetch<{ status: string }>( 58 | "/email/verification-notification", 59 | { 60 | method: "post", 61 | } 62 | ); 63 | } 64 | 65 | async function logout() { 66 | if (!isLoggedIn.value) return; 67 | 68 | await $larafetch("/logout", { method: "post" }); 69 | user.value = null; 70 | 71 | await router.push("/login"); 72 | } 73 | 74 | async function forgotPassword(email: string) { 75 | return await $larafetch<{ status: string }>("/forgot-password", { 76 | method: "post", 77 | body: { email }, 78 | }); 79 | } 80 | 81 | async function resetPassword(credentials: ResetPasswordCredentials) { 82 | return await $larafetch<{ status: string }>("/reset-password", { 83 | method: "post", 84 | body: credentials, 85 | }); 86 | } 87 | 88 | return { 89 | user, 90 | isLoggedIn, 91 | login, 92 | register, 93 | resendEmailVerification, 94 | logout, 95 | forgotPassword, 96 | resetPassword, 97 | refresh, 98 | }; 99 | }; 100 | 101 | export const fetchCurrentUser = async () => { 102 | try { 103 | return await $larafetch("/api/user"); 104 | } catch (error: any) { 105 | if ([401, 419].includes(error?.response?.status)) return null; 106 | throw error; 107 | } 108 | }; 109 | -------------------------------------------------------------------------------- /composables/useLarafetch.ts: -------------------------------------------------------------------------------- 1 | import type { UseFetchOptions } from "nuxt/app"; 2 | 3 | export function useLarafetch( 4 | url: string | (() => string), 5 | options: UseFetchOptions = {} 6 | ) { 7 | return useFetch(url, { 8 | $fetch: $larafetch, 9 | async onResponseError({ response }) { 10 | const status = response.status; 11 | if ([500].includes(status)) { 12 | console.error("[Laravel Error]", response.statusText, response._data); 13 | } 14 | 15 | if ([401, 419].includes(status)) { 16 | navigateTo("/login"); 17 | } 18 | 19 | if ([409].includes(status)) { 20 | navigateTo("/verify-email"); 21 | } 22 | }, 23 | ...options, 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /composables/useSubmit.ts: -------------------------------------------------------------------------------- 1 | export type ValidationErrors = Record; 2 | 3 | export type UseSubmitOptions = { 4 | onSuccess?: (result: any) => any; 5 | onError?: (error: Error) => any; 6 | }; 7 | 8 | export function useSubmit( 9 | fetchable: () => Promise, 10 | options: UseSubmitOptions = {} 11 | ) { 12 | const validationErrors = ref({}); 13 | const error = ref(null); 14 | const inProgress = ref(false); 15 | const succeeded = ref(null); 16 | 17 | async function submit() { 18 | validationErrors.value = {}; 19 | error.value = null; 20 | inProgress.value = true; 21 | succeeded.value = null; 22 | 23 | try { 24 | const data = await fetchable(); 25 | succeeded.value = true; 26 | options?.onSuccess?.(data); 27 | return data; 28 | } catch (e: any) { 29 | error.value = e; 30 | succeeded.value = false; 31 | options?.onError?.(e); 32 | validationErrors.value = e.data?.errors ?? {}; 33 | 34 | if (e.response?.status !== 422) throw e; 35 | } finally { 36 | inProgress.value = false; 37 | } 38 | } 39 | 40 | return { 41 | submit, 42 | inProgress, 43 | succeeded, 44 | validationErrors, 45 | error, 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /layouts/app-layout.vue: -------------------------------------------------------------------------------- 1 | 20 | -------------------------------------------------------------------------------- /layouts/default.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /middleware/auth.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async () => { 2 | const user = useUser(); 3 | if (!user.value) return navigateTo("/login", { replace: true }); 4 | }); 5 | -------------------------------------------------------------------------------- /middleware/guest.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(async () => { 2 | const user = useUser(); 3 | if (user.value) return navigateTo("/dashboard", { replace: true }); 4 | }); 5 | -------------------------------------------------------------------------------- /middleware/unverified.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | const user = useUser(); 3 | 4 | if (!user.value) return navigateTo("/login"); 5 | 6 | // @ts-ignore 7 | if (user.value.email_verified_at || user.value.is_verified) 8 | return navigateTo("/dashboard"); 9 | }); 10 | -------------------------------------------------------------------------------- /middleware/verified.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware(() => { 2 | const user = useUser(); 3 | 4 | if (!user.value) return navigateTo("/login"); 5 | 6 | // @ts-ignore 7 | if (!(user.value.email_verified_at || user.value.is_verified)) 8 | return navigateTo("/verify-email"); 9 | }); 10 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtConfig } from "nuxt/config"; 2 | 3 | // https://v3.nuxtjs.org/api/configuration/nuxt.config 4 | export default defineNuxtConfig({ 5 | modules: ["@nuxtjs/tailwindcss"], 6 | runtimeConfig: { 7 | public: { 8 | backendUrl: "http://localhost:8000", 9 | frontendUrl: "http://localhost:3000", 10 | }, 11 | }, 12 | imports: { 13 | dirs: ["./utils"], 14 | }, 15 | experimental: { 16 | asyncContext: true, 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "nuxt build", 5 | "dev": "nuxt dev", 6 | "generate": "nuxt generate", 7 | "preview": "nuxt preview" 8 | }, 9 | "devDependencies": { 10 | "@nuxtjs/tailwindcss": "^6.1.3", 11 | "@tailwindcss/forms": "^0.5.2", 12 | "nuxt": "^3.0.0" 13 | }, 14 | "dependencies": { 15 | "@headlessui/vue": "^1.6.5" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pages/[...slug].vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /pages/dashboard.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 28 | -------------------------------------------------------------------------------- /pages/forgot-password.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 69 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 273 | -------------------------------------------------------------------------------- /pages/login.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 99 | -------------------------------------------------------------------------------- /pages/password-reset/[token].vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 86 | -------------------------------------------------------------------------------- /pages/register.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 99 | -------------------------------------------------------------------------------- /pages/verify-email.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 50 | -------------------------------------------------------------------------------- /plugins/auth.ts: -------------------------------------------------------------------------------- 1 | import { useUser, fetchCurrentUser } from "~/composables/useAuth"; 2 | 3 | export default defineNuxtPlugin(async () => { 4 | const user = useUser(); 5 | 6 | // Skip if already initialized on server 7 | if (user.value !== undefined) return; 8 | 9 | user.value = await fetchCurrentUser(); 10 | }); 11 | -------------------------------------------------------------------------------- /plugins/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { FetchError } from "ofetch"; 2 | 3 | export default defineNuxtPlugin(async (nuxtApp) => { 4 | nuxtApp.hook("vue:error", (error, instance, info) => { 5 | if (!(error instanceof FetchError)) throw error; 6 | 7 | const status = error.response?.status ?? -1; 8 | 9 | if ([401, 419].includes(status)) { 10 | navigateTo("/login"); 11 | } 12 | 13 | if ([409].includes(status)) { 14 | navigateTo("/verify-email"); 15 | } 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const defaultTheme = require("tailwindcss/defaultTheme"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | content: [], 6 | theme: { 7 | extend: { 8 | fontFamily: { 9 | sans: ["Nunito", ...defaultTheme.fontFamily.sans], 10 | }, 11 | }, 12 | }, 13 | plugins: [require("@tailwindcss/forms")], 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://v3.nuxtjs.org/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /utils/$larafetch.ts: -------------------------------------------------------------------------------- 1 | import { $fetch, FetchError } from "ofetch"; 2 | import { parseCookies } from "h3"; 3 | 4 | const CSRF_COOKIE = "XSRF-TOKEN"; 5 | const CSRF_HEADER = "X-XSRF-TOKEN"; 6 | 7 | export const $larafetch = $fetch.create({ 8 | credentials: "include", 9 | async onRequest({ request, options }) { 10 | const { backendUrl, frontendUrl } = useRuntimeConfig().public; 11 | const event = typeof useEvent === "function" ? useEvent() : null; 12 | let token = event 13 | ? parseCookies(event)[CSRF_COOKIE] 14 | : useCookie(CSRF_COOKIE).value; 15 | 16 | // on client initiate a csrf request and get it from the cookie set by laravel 17 | if ( 18 | process.client && 19 | ["post", "delete", "put", "patch"].includes( 20 | options?.method?.toLowerCase() ?? "" 21 | ) 22 | ) { 23 | token = await initCsrf(); 24 | } 25 | 26 | let headers: any = { 27 | accept: "application/json", 28 | ...options?.headers, 29 | ...(token && { [CSRF_HEADER]: token }), 30 | }; 31 | 32 | if (process.server) { 33 | const cookieString = event 34 | ? event.headers.get("cookie") 35 | : useRequestHeaders(["cookie"]).cookie; 36 | 37 | headers = { 38 | ...headers, 39 | ...(cookieString && { cookie: cookieString }), 40 | referer: frontendUrl, 41 | }; 42 | } 43 | 44 | options.headers = headers; 45 | options.baseURL = backendUrl; 46 | }, 47 | async onResponseError({ response }) { 48 | const status = response.status; 49 | if ([500].includes(status)) { 50 | console.error("[Laravel Error]", response.statusText, response._data); 51 | } 52 | }, 53 | }); 54 | 55 | async function initCsrf() { 56 | const { backendUrl } = useRuntimeConfig().public; 57 | const existingToken = useCookie(CSRF_COOKIE).value; 58 | 59 | if (existingToken) return existingToken; 60 | 61 | await $fetch("/sanctum/csrf-cookie", { 62 | baseURL: backendUrl, 63 | credentials: "include", 64 | }); 65 | 66 | return useCookie(CSRF_COOKIE).value; 67 | } 68 | --------------------------------------------------------------------------------