1

I have a Nuxt JS app setup to use Nuxt Auth. This works fine locally.

Specifically I am generating an email sent to the user with a link to reset their password of the form

http://localhost:3000/reset-password/ca62c3554c8058c9ddf11b709fc451405ffa99f4b22a88d84e087f5b40fb6d1f

When they click it - its picked up by a nuxt route which parses the JWT. Locally I serve it using nuxt start - which serves from the dist directory I believe and so should be a good test for static serving

When I deploy this to a remote lightsail server running Ubuntu and Plesk and Nginx and Apache I deploy it using nuxt generate and copy the content of the generated dist directory to the httpdocs directory. When the same workflow is followed and the user clicks the link it is not caught by one of the nuxt generated static html files and I get a 404. All other nuxt routes are being generated into files ok. What am I missing?

nuxt.config.js

export default {
  target: 'static',
  loading: {
    color: '#3700b3',
    height: '5px',
  },
  env: {
    apiUrl: process.env.NODE_ENV === 'production' ? process.env.PLATFORM_API_URL : 'http://localhost:8000',
    mainUrl: process.env.NODE_ENV === 'production' ? process.env.PLATFORM_URL : 'http://localhost:3000',
    googleSiteKey: process.env.RECAPTCHA_SITE_KEY || '',
  },
  ssr: false,
  head: {
    titleTemplate: `%s - ${process.env.PLATFORM_NAME || 'Some platform name'}`,
    title: process.env.PLATFORM_NAME || 'Some platform name',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
      { hid: 'description', name: 'description', content: 'Virtua Centre' },
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
    script: [
      {
        src: 'https://platform.twitter.com/widgets.js',
      },
      {
        src: 'https://js.stripe.com/v3',
      },
    ],
  },
  plugins: [
    {
      src: '@/plugins/vue-page-transition.js',
    },
    {
      src: '@/plugins/plateform-detector.js',
    },
    { src: '~/plugins/TiptapVuetify', mode: 'client' },
    { src: '@/plugins/filters.js' },
    { src: '~/plugins/i18n.js' },
    { src: '~/plugins/locales.js' },
  ],
  components: true,
  buildModules: [
    '@nuxtjs/eslint-module',
    '@nuxtjs/stylelint-module',
    ['@nuxtjs/vuetify'],
    '@nuxtjs/date-fns',
  ],
  modules: [
    'nuxt-i18n',
    '@nuxtjs/axios',
    '@nuxtjs/auth-next',
    ['v-currency-field/nuxt-treeshaking'],
    'vue-currency-filter/nuxt',
    'vuetify-dialog/nuxt',
  ],
  i18n: {
    strategy: 'no_prefix',
    locales: [
      {
        code: 'en',
        name: 'English',
        file: 'en-US.js',
        flag: '/flag-icon/flags/1x1/us.svg',
      },
      {
        code: 'kk',
        name: 'Kazakh',
        file: 'en-KK.js',
        flag: '/flag-icon/flags/1x1/kz.svg',
      },
      {
        code: 'ru',
        name: 'Russian',
        file: 'en-RU.js',
        flag: '/flag-icon/flags/1x1/ru.svg',
      },
    ],
    lazy: true,
    langDir: 'lang',
    defaultLocale: 'en',
    vueI18n: {
      fallbackLocale: 'en',
    },
  },
  axios: {
    credentials: true,
    baseURL: process.env.NODE_ENV === 'production' ? process.env.PLATFORM_API_URL : 'http://localhost:8000',
  },
  auth: {
    redirect: {
      login: '/login',
      logout: false,
      callback: '/',
      home: false,
    },
    strategies: {
      local: {
        token: {
          property: 'data.access_token',
          maxAge: 36000,
        },
        user: {
          property: 'data',
        },
        endpoints: {
          login: { url: '/auth/login', method: 'post' },
          logout: { url: '/logout', method: 'post' },
          user: { url: '/me', method: 'get' },
        },
      },
    },
  },
  vue: {
    config: {
      productionTip: false,
      devtools: true,
    },
  },
  vuetify: {
    theme: {
      themes: {
        light: {
          primary: '#4F91FF',
          secondary: '#00109c',
          success: '#00B485',
          lsmbutton: '#FFBF42',
          error: '#F85032',
        },
      },
    },
  },
  build: {
    extractCSS: true,
    transpile: ['vuetify/lib', 'tiptap-vuetify', 'vee-validate/dist/rules'],
    babel: {
      plugins: [['@babel/plugin-proposal-private-methods', { loose: true }]],
    },
    extend(config, ctx) {
      config.module.rules.push({
        test: /\.(ogg|mp3|wav|mpe?g)$/i,
        loader: 'file-loader',
        options: {
          name: '[path][name].[ext]',
        },
      })
    },
    splitChunks: {
      layouts: true,
    },
  },
}

package.json's scripts section

"scripts": {
  "dev": "nuxt --hostname 127.0.0.1 --port 3000",
  "build": "nuxt build",
  "start": "nuxt start",
  "generate": "nuxt generate",
  "lint:js": "eslint --ext .js,.vue --ignore-path .gitignore .",
  "lintfix": "eslint --fix --ext .vue --ignore-path .gitignore .",
  "lint:style": "stylelint **/*.{vue,css} --ignore-path .gitignore",
  "lint": "npm run lint:js && npm run lint:style",
  "test": "jest"
},

I am using npm

Reading around I can see that the standard solution to handling dynamic routes is to update the config in nuxt.config.js generate.routes section. As detailed within this medium article

This seems to work by grabbing all values from the server at generate time. I don't think this is applicable for authentication tokens since users could register at any time - notably after nuxt generate has been run.

Reset Password Functionality

  • pages
  • reset-password
  • index.vue
  • _token.vue

index.vue

    <template>
      <div v-show="!loading">
        <section
          class="login-bg"
          :class="[$vuetify.breakpoint.mdAndUp ? 'xl-full-width' : 'sm-full-width']"
        >
          <v-row class="justify-center-custom">
            <div
              class="cont mb-5"
              :class="[$vuetify.breakpoint.smAndUp ? 'small-width' : 'full-width']"
            >
              <div class="form-right">
                <div class="card-body">
                  <h3 class="text-center">
                    <!-- TODO: translate -->
                    <strong>Forgot Password?</strong>
                  </h3>
                  <form v-if="!message" @submit.prevent="submit()">
                    <p
                      class="text-center"
                      style="margin-top: 10px; margin-bottom: 10px"
                    >
                      Enter the email ID you used when you joined and we will send
                      you temporary password
                    </p>
                    <br />
                    <div class="form-group mb-50">
                      <label class="text-bold-600">{{ 'E-Mail Address' }}</label>
                      <input
                        id="email"
                        v-model="email"
                        type="email"
                        class="form-control"
                        :class="{ 'is-invalid': error.email }"
                        name="email"
                        required
                        autocomplete="off"
                        autofocus
                      />
                      <span
                        v-if="error.email"
                        class="invalid-feedback"
                        role="alert"
                      >
                        <strong>{{ error.email }}</strong>
                      </span>
                    </div>
                    <button
                      type="submit"
                      class="btn ml-0 btn-login btn-primary w-100"
                    >
                      <span
                        v-if="formSubmitting"
                        class="spinner-border spinner-border-sm mr-1"
                        role="status"
                        aria-hidden="true"
                      ></span>
                      {{ 'Send Password Reset Link' }}
                      <i class="fa fa-arrow-right"></i>
                    </button>
                  </form>
                  <div v-else>
                    <p
                      class="text-center text-success"
                      style="margin-top: 10px; margin-bottom: 10px"
                    >
                      {{ message }}
                    </p>
                  </div>
                </div>
              </div>
            </div>
          </v-row>
        </section>
      </div>
    </template>
    
    <script>
    import AssetLoader from '@/mixins/AssetLoader'
    
    export default {
      layout: 'landing',
      mixins: [AssetLoader],
      data() {
        return {
          error: {
            email: false,
          },
          email: null,
          loading: false,
          formSubmitting: false,
          message: '',
        }
      },
    
      async beforeDestroy() {
        await this.unloadCSS(
          'https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css'
        )
        await this.unloadCSS(
          'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,800;1,900&display=swap'
        )
        await this.unloadCSS('/css/mdb.min.css')
        await this.unloadCSS('/css/style.css')
        await this.unloadCSS('/css/landing-school.css')
        await this.unloadCSS('/css/landing-school-options.css')
        await this.unloadCSS(
          'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css'
        )
        await this.unloadScript('/js/jquery-3.4.1.min.js')
        await this.unloadScript('/js/popper.min.js')
        await this.unloadScript('/js/bootstrap.min.js')
        await this.unloadScript('/js/popup.js')
        await this.unloadScript('/js/owl.carousel.js')
        await this.unloadScript('/js/jquery.nivo.slider.js')
        await this.unloadScript('/js/landing-school.js')
      },
      async mounted() {
        this.loading = true
        await this.loadCSS([
          'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css',
          'https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css',
          'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,800;1,900&display=swap',
          '/css/mdb.min.css',
          '/css/landing-school.css',
          '/css/landing-school-options.css',
        ])
        await this.loadJS([
          '/js/jquery-3.4.1.min.js',
          '/js/popper.min.js',
          '/js/bootstrap.min.js',
          '/js/popup.js',
          '/js/owl.carousel.js',
          '/js/jquery.nivo.slider.js',
          // '/js/landing-school.js'
        ])
        this.loading = false
      },
    
      methods: {
        async submit() {
          this.formSubmitting = true
          this.error.email = ''
          try {
            const { data } = await this.$axios.post('/auth/forgot-password', {
              email: this.email,
            })
            this.message = data.status
            this.email = ''
            this.formSubmitting = false
          } catch (error) {
            this.formSubmitting = false
            if (error?.response?.data?.errorsArray?.length) {
              this.error.email = error?.response?.data?.errorsArray[0]
            } else if (error?.response?.data?.email) {
              this.error.email = error?.response?.data?.email
            } else {
              this.error.email = error.message
            }
          }
        },
      },
    }
    </script>
    
    <style scoped>
    .navbar {
      display: none !important;
    }
    #btn-amazon {
      background: #f90 !important;
      border-color: #f90 !important;
      color: #fff !important;
    }
    #btn-apple {
      background: #7e878b !important;
      border-color: #7e878b !important;
      color: #fff !important;
    }
    #btn-twitter {
      background: #32def4 !important;
      border-color: #32def4 !important;
      color: #fff !important;
    }
    .btn-login {
      background: -webkit-linear-gradient(45deg, #303f9f, #7b1fa2);
      background: linear-gradient(45deg, #303f9f, #7b1fa2);
      box-shadow: 3px 3px 20px 0 rgba(123, 31, 162, 0.5);
    }
    .form-left {
      padding: 114px 10px;
    }
    .cont {
      border-radius: 10px;
      display: block;
    }
    .cont.full-width {
      width: 90%;
    }
    .cont.small-width {
      width: 600px;
    }
    .row.justify-center-custom {
      justify-content: center;
    }
    .login-bg.xl-full-width {
      height: 100vh;
    }
    .login-bg.sm-full-width {
      height: 100%;
    }
    </style>

_token.vue

<template>
  <div v-show="!loading">
    <section
      class="login-bg"
      :class="[$vuetify.breakpoint.mdAndUp ? 'xl-full-width' : 'sm-full-width']"
    >
      <v-row class="justify-center-custom">
        <div
          class="cont mb-5"
          :class="[$vuetify.breakpoint.smAndUp ? 'small-width' : 'full-width']"
        >
          <div class="form-right">
            <div class="card-body">
              <h3 class="text-center">
                <!-- TODO: translate -->
                <strong>Reset Password</strong>
              </h3>
              <form v-if="!message" @submit.prevent="submit()">
                <p
                  class="text-center"
                  style="margin-top: 10px; margin-bottom: 10px"
                >
                  Enter the email ID you used when you joined and we will send
                  you temporary password
                </p>
                <br />
                <div v-if="errors.length" class="card px-3 py-3 mb-3">
                  <ol class="text-danger mb-0" style="list-style-type: none">
                    <li v-for="(error, index) in errors" :key="index">
                      <strong>{{ error }}</strong>
                    </li>
                  </ol>
                </div>
                <div class="form-group mb-50">
                  <label class="text-bold-600">{{ 'E-Mail Address' }}</label>
                  <input
                    v-model="email"
                    type="email"
                    class="form-control"
                    name="email"
                    autocomplete="off"
                    autofocus
                  />
                </div>
                <div class="form-group mb-50">
                  <label class="text-bold-600">{{ 'Password' }}</label>
                  <input
                    v-model="password"
                    type="password"
                    class="form-control"
                    autocomplete="off"
                    autofocus
                  />
                </div>
                <div class="form-group mb-50">
                  <label class="text-bold-600">{{ 'Confirm Password' }}</label>
                  <input
                    v-model="password_confirmation"
                    type="password"
                    class="form-control"
                    autocomplete="off"
                    autofocus
                  />
                </div>
                <button
                  type="submit"
                  class="btn ml-0 btn-login btn-primary w-100"
                >
                  <span
                    v-if="formSubmitting"
                    class="spinner-border spinner-border-sm mr-1"
                    role="status"
                    aria-hidden="true"
                  ></span>
                  {{ 'Reset Password' }}
                  <i class="fa fa-arrow-right"></i>
                </button>
              </form>
              <div v-else>
                <p
                  class="text-center text-success"
                  style="margin-top: 10px; margin-bottom: 10px"
                >
                  {{ message }}
                </p>
                <button
                  type="button"
                  class="btn ml-0 btn-login btn-primary w-100"
                  @click="$router.push('/login')"
                >
                  {{ $t('login_now') }}
                  <i class="fa fa-arrow-right"></i>
                </button>
              </div>
            </div>
          </div>
        </div>
      </v-row>
    </section>
  </div>
</template>

<script>
import AssetLoader from '@/mixins/AssetLoader'

export default {
  layout: 'landing',
  mixins: [AssetLoader],
  data() {
    return {
      errors: [],
      token: null,
      email: null,
      password: null,
      password_confirmation: null,
      loading: false,
      formSubmitting: false,
      message: '',
    }
  },

  created() {
    if (this.$route.params.token) {
      this.token = this.$route.params.token
    }
  },

  async beforeDestroy() {
    await this.unloadCSS(
      'https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css'
    )
    await this.unloadCSS(
      'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,800;1,900&display=swap'
    )
    await this.unloadCSS('/css/mdb.min.css')
    await this.unloadCSS('/css/style.css')
    await this.unloadCSS('/css/landing-school.css')
    await this.unloadCSS('/css/landing-school-options.css')
    await this.unloadCSS(
      'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css'
    )
    await this.unloadScript('/js/jquery-3.4.1.min.js')
    await this.unloadScript('/js/popper.min.js')
    await this.unloadScript('/js/bootstrap.min.js')
    await this.unloadScript('/js/popup.js')
    await this.unloadScript('/js/owl.carousel.js')
    await this.unloadScript('/js/jquery.nivo.slider.js')
    await this.unloadScript('/js/landing-school.js')
  },
  async mounted() {
    this.loading = true
    await this.loadCSS([
      'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css',
      'https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css',
      'https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,800;1,900&display=swap',
      '/css/mdb.min.css',
      '/css/landing-school.css',
      '/css/landing-school-options.css',
    ])
    await this.loadJS([
      '/js/jquery-3.4.1.min.js',
      '/js/popper.min.js',
      '/js/bootstrap.min.js',
      '/js/popup.js',
      '/js/owl.carousel.js',
      '/js/jquery.nivo.slider.js',
      // '/js/landing-school.js'
    ])
    this.loading = false
  },

  methods: {
    async submit() {
      this.formSubmitting = true
      this.errorsl = []
      try {
        const { data } = await this.$axios.post('/auth/reset-password', {
          email: this.email,
          token: this.token,
          password_confirmation: this.password_confirmation,
          password: this.password,
        })
        this.message = data.status
        this.email = ''
        this.formSubmitting = false
      } catch (error) {
        this.formSubmitting = false
        if (error?.response?.data?.errorsArray?.length) {
          this.errors = error?.response?.data?.errorsArray
        } else if (error?.response?.data?.email) {
          this.errors = [error?.response?.data?.email]
        }
      }
    },
  },
}
</script>

<style scoped>
.navbar {
  display: none !important;
}
#btn-amazon {
  background: #f90 !important;
  border-color: #f90 !important;
  color: #fff !important;
}
#btn-apple {
  background: #7e878b !important;
  border-color: #7e878b !important;
  color: #fff !important;
}
#btn-twitter {
  background: #32def4 !important;
  border-color: #32def4 !important;
  color: #fff !important;
}
.btn-login {
  background: -webkit-linear-gradient(45deg, #303f9f, #7b1fa2);
  background: linear-gradient(45deg, #303f9f, #7b1fa2);
  box-shadow: 3px 3px 20px 0 rgba(123, 31, 162, 0.5);
}
.form-left {
  padding: 114px 10px;
}
.cont {
  border-radius: 10px;
  display: block;
}
.cont.full-width {
  width: 90%;
}
.cont.small-width {
  width: 600px;
}
.row.justify-center-custom {
  justify-content: center;
}
.login-bg.xl-full-width {
  height: 100vh;
}
.login-bg.sm-full-width {
  height: 100%;
}
</style>

Ultimately the answer for making this work on Plesk Apache was to add an .htaccess file to the same dir as index.html. Contents as:-

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>

Explained here

Simon Taylor
  • 607
  • 1
  • 9
  • 27
  • Can you share your `nuxt.config.js` file please? Also, what are you using locally to work? `yarn build && yarn start` or something else? – kissu Jun 30 '21 at 15:44
  • So, to try it out locally you do `nuxt generate` and `nuxt start`, right? – kissu Jul 08 '21 at 10:29
  • Is your app working on another platform? Like Netlify or Vercel? Your dynamic pages should be good without doing it manually, if there are local and not fetched from an API. Can you please show the `reset-password` page in your IDE and it's content? – kissu Jul 08 '21 at 10:32
  • Added the reset-password index.vue and _token.vue – Simon Taylor Jul 08 '21 at 14:10
  • I don't have it on any other platform other than the plesk server im serving it from and testing it locally using local python http server. – Simon Taylor Jul 08 '21 at 14:12
  • There is a lot happening in the 2 given files. Maybe try to narrow it down to see if one of your scripts is not breaking it at some point. You're not running it with the given webpack server? Not sure how this one should behave if used with something else than the expected webpack server. Also, could you please try to host it on either Netlify or Vercel? This will help us see if it's a build or a code issue. Looking at the given `nuxt.config.js`, I think that your solution should work but I cannot be sure where this is coming from because of your specific build. – kissu Jul 08 '21 at 14:35
  • im using npm run generate to create static html files. Haven't tried hosting it on Netlify or Vercel yet - needs a backend api - have to look into that. – Simon Taylor Jul 08 '21 at 15:21
  • Try to use `npm run start` once the files are generated yeah. If you have a backend to host, look into Heroku, fast and easy (depends of your backend stack I guess). – kissu Jul 08 '21 at 15:37
  • Deployed to Netlify. Get the same issue there also. Seems the Netlify documentation https://www.netlify.com/blog/2020/09/10/the-new-target-static-mode-in-nuxt/ on dynamic routes suggests mode:universal or ssr:true and target:static now mode:universal has been deprecated. I get many more errors generating the site now. – Simon Taylor Jul 09 '21 at 13:55
  • `mode` is deprecated as shown here: https://stackoverflow.com/questions/68272663/how-to-setup-nuxt-for-an-isomorphic-universal-app What is not working on Netlify? Your file structure is `pages/reset_password/_token.vue` right? What if you try a fresh project, does it work with this simple structure? – kissu Jul 09 '21 at 13:59
  • If I hit the main page of my site and use Vue dev tools - within routes I can see reset-password-token route. So the vue JS for that is in place. Im confused as to why its not being trigged when a url that looks like it should match that pattern is clicked. Any ideas how to troubleshoot that? – Simon Taylor Jul 09 '21 at 14:23
  • Hm, I guess that we will need some [repro] to go further from there. – kissu Jul 09 '21 at 14:25
  • ok let me work out how to pull that together. Let me ask you - would you expect this route (pages/reset_password/_token.vue) to be generated from npm run generate with nuxt.config.js or target: static and ssr: false? – Simon Taylor Jul 09 '21 at 15:53
  • Hi, I'm not sure that I quite get the question, meanwhile if you can, `npm run generate` with `target: static` and `ssr: true`, for all the benefits of SEO. Btw, I saw that you found a solution to your issue. Mind if I post an answer and you'll give me the bounty? Otherwise, it will be lost for nothing. – kissu Jul 12 '21 at 13:08

2 Answers2

1

The solution for this on Netlify was to add some specific configuration to the build for redirects. Created netlify.toml in the root of the repo branch being deployed from.

Netlify.toml contained:-

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

Guided from this

I read this as - in cases where nuxt has generated an html file it will serve it and your route will be good. But in cases where it hasn't generated a file with an exact match for the route then you need to call the entry point to the application to initialise it and make the route available that you need.

Simon Taylor
  • 607
  • 1
  • 9
  • 27
0

Not sure about your Plesk + Nginx + Apache specific configuration, but hosting it on Netlify is still the easiest and fastest solution.

Also, I'd go target: static and ssr: true if doable.

kissu
  • 40,416
  • 14
  • 65
  • 133
  • 1
    Found the answer I was looking for re Nuxt static sites on Plesk Apache. Needed to add .htaccess into same dir as index.html Containing the following:- RewriteEngine On RewriteBase / RewriteRule ^index\.html$ - [L] RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule . /index.html [L] – Simon Taylor Jul 15 '21 at 10:31