Create a new Phoenix project with mix phx.new vue_demo --no-ecto. We're not including Ecto as we won't be needing any database. When prompted to install dependencies, choose Y.

I set my Elixir version to 1.8 in mix.exs; you can set yours to any version you like.

I prefer using Laravel Mix to manage assets as it is easier to work with than directly working with Webpack. I also like to use EsLint for any significant amount of Javascript, so we'll make that a part of our setup.

In the assets directory, rename webpack.config.js to webpack.mix.js and replace the contents with:

const mix = require('laravel-mix');
const path = require('path');
require('laravel-mix-eslint');

mix.setPublicPath('../priv/static')
  .js('js/app.js', 'js/app.js')
  .eslint()
  .extract(Object.keys(require('./package.json').dependencies))
  .sass('css/app.scss', 'css/app.css')
  .version()
  .copyDirectory('./static', '../priv/static')
  .webpackConfig({
    resolve: {
      extensions: ['.vue', '.js'],
      alias: {
        '@': path.resolve(__dirname, 'js')
      }
    }
  })
  .options({
    clearConsole: false,
    processCssUrls: false
  });

Update the scripts, dependencies and devDependencies of package.json to:

{
  "repository": {},
  "license": "MIT",
  "scripts": {
    "build": "NODE_ENV=production webpack -p --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
    "watch": "NODE_ENV=development webpack --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
    "dev": "NODE_ENV=development webpack --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
  },
  "dependencies": {
    "axios": "^0.19.0",
    "bootstrap-vue": "^2.0.0-rc.24",
    "vue": "^2.6.10"
  },
  "devDependencies": {
    "@babel/core": "^7.3.3",
    "@babel/preset-env": "^7.0.0",
    "css-loader": "^2.1.0",
    "eslint": "^5.12.0",
    "eslint-config-airbnb-base": "^13.1.0",
    "eslint-friendly-formatter": "^4.0.1",
    "eslint-import-resolver-webpack": "^0.11.0",
    "eslint-loader": "^2.1.2",
    "eslint-plugin-import": "^2.16.0",
    "eslint-plugin-vue": "^5.2.2",
    "laravel-mix": "^4.0.14",
    "laravel-mix-eslint": "^0.1.2",
    "sass": "^1.17.0",
    "sass-loader": "^7.1.0",
    "vue-loader": "^15.6.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.6.10",
    "webpack": "^4.29.4",
    "webpack-cli": "^3.2.3"
  }
}

We're including bootstrap-vue so that we can get bootstrap without bringing in jquery.

In the assets/css directory, delete phoenix.css and rename app.css to app.scss. Replace the contents of app.scss with:

@import '~bootstrap/scss/bootstrap';
@import '~bootstrap-vue/src/index.scss';

/* General style */
h1{font-size: 3.6rem; line-height: 1.25}
h2{font-size: 2.8rem; line-height: 1.3}
h3{font-size: 2.2rem; letter-spacing: -.08rem; line-height: 1.35}
h4{font-size: 1.8rem; letter-spacing: -.05rem; line-height: 1.5}
h5{font-size: 1.6rem; letter-spacing: 0; line-height: 1.4}
h6{font-size: 1.4rem; letter-spacing: 0; line-height: 1.2}

.container{
  margin: 0 auto;
  max-width: 80.0rem;
  padding: 0 2.0rem;
  position: relative;
  width: 100%
}
select {
  width: auto;
}

/* Alerts and form errors */
.alert {
  padding: 15px;
  margin-bottom: 20px;
  border: 1px solid transparent;
  border-radius: 4px;
}
.alert-info {
  color: #31708f;
  background-color: #d9edf7;
  border-color: #bce8f1;
}
.alert-warning {
  color: #8a6d3b;
  background-color: #fcf8e3;
  border-color: #faebcc;
}
.alert-danger {
  color: #a94442;
  background-color: #f2dede;
  border-color: #ebccd1;
}
.alert p {
  margin-bottom: 0;
}
.alert:empty {
  display: none;
}
.help-block {
  color: #a94442;
  display: block;
  margin: -1rem 0 2rem;
}

/* Phoenix promo and logo */
.phx-hero {
  text-align: center;
  border-bottom: 1px solid #e3e3e3;
  background: #eee;
  border-radius: 6px;
  padding: 3em;
  margin-bottom: 3rem;
  font-weight: 200;
  font-size: 120%;
}
.phx-hero p {
  margin: 0;
}
.phx-logo {
  min-width: 300px;
  margin: 1rem;
  display: block;
}
.phx-logo img {
  width: auto;
  display: block;
}

/* Headers */
header {
  width: 100%;
  background: #fdfdfd;
  border-bottom: 1px solid #eaeaea;
  margin-bottom: 2rem;
}
header section {
  align-items: center;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
header section :first-child {
  order: 2;
}
header section :last-child {
  order: 1;
}
header nav ul,
header nav li {
  margin: 0;
  padding: 0;
  display: block;
  text-align: right;
  white-space: nowrap;
}
header nav ul {
  margin: 1rem;
  margin-top: 0;
}
header nav a {
  display: block;
}

@media (min-width: 40.0rem) { /* Small devices (landscape phones, 576px and up) */
  header section {
    flex-direction: row;
  }
  header nav ul {
    margin: 1rem;
  }
  .phx-logo {
    flex-basis: 527px;
    margin: 2rem 1rem;
  }
}

For EsLint, create two files in the assets directory, .eslintignore and .eslintrc.js.

Paste the below into .eslintignore:

deps/
assets/node_modules

I use a modified version of Airbnb's EsLint config, but you can use any one of your choice. My .eslintrc.js is shown below:

// https://eslint.org/docs/user-guide/configuring

module.exports = {
  root: true,
  parserOptions: {
    ecmaVersion: 2017
  },
  env: {
    node: true,
    browser: true
  },
  // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
  // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
  extends: ['plugin:vue/recommended', 'airbnb-base'],
  // required to lint *.vue files
  plugins: [
    'vue'
  ],
  // check if imports actually resolve
  settings: {
    'import/resolver': {
      webpack: {
        config: 'node_modules/laravel-mix/setup/webpack.config.js'
      }
    }
  },
  // add your custom rules here
  rules: {
    // don't require .vue extension when importing
    'import/extensions': ['error', 'always', {
      js: 'never',
      vue: 'never'
    }],
    // disallow reassignment of function parameters
    // disallow parameter object manipulation except for specific exclusions
    'no-param-reassign': ['error', {
      props: true,
      ignorePropertyModificationsFor: [
        'state', // for vuex state
        'acc', // for reduce accumulators
        'e' // for e.returnvalue
      ]
    }],
    // allow optionalDependencies
    'import/no-extraneous-dependencies': 'off',
    // allow debugger during development
    'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
    'no-confusing-arrow': 'off',
    'no-new': 'off',
    'no-new-wrappers': 'off',
    'no-multi-assign': 'off',
    'no-restricted-globals': 'off',
    'object-shorthand': 'off',
    'operator-linebreak': 'off',
    'no-underscore-dangle': 'off',
    'no-alert': 'off',
    'no-console': 'off',
    'object-curly-newline': 'off',
    'camelcase': 'off',
    'yoda': 'off',
    'comma-dangle': ['error', 'never'],
    'max-len': ['error', 120, 2, { ignoreComments: true }],
    'no-unused-vars': ['warn', { 'vars': 'local', 'args': 'none' }],
    'no-cond-assign': ['error', 'except-parens'],
    'no-nested-ternary': 'off',
    'no-trailing-spaces': 'off',
    'import/prefer-default-export': 'off',
    'linebreak-style': 'off',
    'no-continue': 'off'
  }
}

Our Webpack setup is configured to lint our files when they change (this is achieved by line 3 of our webpack.mix.js).

Since we're not using the default asset setup, we need to update the watcher in dev.exs. Update the endpoint config to:

config :vue_demo, VueDemoWeb.Endpoint,
  http: [port: 4000],
  debug_errors: true,
  code_reloader: true,
  check_origin: false,
  watchers: [npm: ["run", "watch", cd: Path.expand("../assets", __DIR__)]]

Replace the contents of assets/js/app.js with:

import Vue from 'vue';
import BootstrapVue from 'bootstrap-vue';
import axios from 'axios';
import Example from '@/components/Example';

Vue.config.productionTip = false;

Vue.use(BootstrapVue);
Vue.component(Example.name, Example);

const token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
  axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
}

new Vue({ el: '#app' });

In assets/js/components, create 3 files Example.vue, Header.vue and Page.vue. Paste the below in Example.vue:

<template>
  <!-- eslint-disable -->
  <Page :logo="logo">
    <section class="phx-hero">
      <h1>{{ welcomeText }}</h1>
      <p>A productive web framework that<br/>does not compromise speed and maintainability.</p>
    </section>

    <section class="row">
      <article class="column">
        <h2>Resources</h2>
        <ul>
          <li>
            <a href="https://hexdocs.pm/phoenix/overview.html">Guides &amp; Docs</a>
          </li>
          <li>
            <a href="https://github.com/phoenixframework/phoenix">Source</a>
          </li>
          <li>
            <a href="https://github.com/phoenixframework/phoenix/blob/v1.4/CHANGELOG.md">v1.4 Changelog</a>
          </li>
        </ul>
      </article>
      <article class="column">
        <h2>Help</h2>
        <ul>
          <li>
            <a href="https://elixirforum.com/c/phoenix-forum">Forum</a>
          </li>
          <li>
            <a href="https://webchat.freenode.net/?channels=elixir-lang">#elixir-lang on Freenode IRC</a>
          </li>
          <li>
            <a href="https://twitter.com/elixirphoenix">Twitter @elixirphoenix</a>
          </li>
        </ul>
      </article>
    </section>
  </Page>
</template>

<script>
import Page from './Page';

export default {
  name: 'Example',
  components: {
    Page
  },
  props: {
    logo: {
      type: String,
      required: true
    },
    welcomeText: {
      type: String,
      required: true
    }
  }
};
</script>

Paste this into Header.vue:

<template>
  <!-- eslint-disable -->
  <header>
    <section class="container">
      <nav role="navigation">
        <ul>
          <li><a href="https://hexdocs.pm/phoenix/overview.html">Get Started</a></li>
        </ul>
      </nav>
      <a href="http://phoenixframework.org/" class="phx-logo">
        <img :src="logo" alt="Phoenix Framework Logo"/>
      </a>
    </section>
  </header>
</template>

<script>
export default {
  name: 'Header',
  props: {
    logo: {
      type: String,
      required: true
    }
  }
};
</script>

Paste this into Page.vue:

<template>
  <!-- eslint-disable -->
  <div>
    <custom-header :logo="logo" />
    <main class="container" role="main">
      <slot />
    </main>
  </div>
</template>

<script>
import Header from './Header';

export default {
  name: 'Page',
  components: {
    'custom-header': Header
  },
  props: {
    logo: {
      type: String,
      required: true
    }
  }
};
</script>

I like naming my web directory web instead of vue_demo_web or *_web, so I'll rename the directory and the 3 occurrences of vue_demo_web ( config/dev.exs and lib/vue_demo_web.ex) to web, then rename lib/vue_demo_web.ex to lib/web.ex, lib/vue_demo_web to lib/web and test/vue_demo_web to test/web.

Also, I'll rename all occurrences of VueDemoWeb to VueDemo.Web  as I prefer it that way.

Replace the contents of lib/web/templates/layout/app.html.eex with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
    <title>VueDemo · Phoenix Framework</title>
    <meta name="csrf-token" content="<%= Phoenix.Controller.get_csrf_token() %>"/>
    <link rel="stylesheet" href="<%= Routes.static_path(@conn, "/css/app.css") %>"/>
  </head>
  <body>
    <%= render @view_module, @view_template, assigns %>

    <script type="text/javascript" src='<%= Routes.static_path(@conn, "/js/manifest.js") %>'></script>
    <script type="text/javascript" src='<%= Routes.static_path(@conn, "/js/vendor.js") %>'></script>
    <script type="text/javascript" src='<%= Routes.static_path(@conn, "/js/app.js") %>'></script>
  </body>
</html>

Replace the contents of lib/web/templates/page/index.html.eex with:

<div id="app">
  <example
    logo="<%= Routes.static_path(@conn, "/images/phoenix.png") %>"
    welcome-text="<%= gettext "Welcome to %{name}!", name: "Phoenix" %>" />
</div>

In the assets directory, run rm -rf node_modules package-lock.json  and npm i to remove the default installed packages and install our packages.

Now, when you run iex -S mix phx.server and visit localhost:4000 in your browser and you should see the default page loaded using Vue.

You can find the repo here.