Building Authentication UI
In this lesson, we'll build complete sign up, sign in, and sign out functionality using Vue components and the Amplify auth composable.
Two Approaches
AWS Amplify offers two ways to implement auth UI:
| Approach | Pros | Cons |
|---|---|---|
| Amplify UI Components | Pre-built, handles all flows | Less customization, larger bundle |
| Custom Components | Full control, matches your design | More code to write and maintain |
We'll build custom components to understand exactly how authentication works.
Create Auth Pages
Sign Up Page
Create pages/auth/signup.vue:
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const { signUp, isLoading, error, clearError } = useAmplifyAuth();
const form = reactive({
email: '',
password: '',
confirmPassword: '',
});
const validationError = ref('');
const handleSubmit = async () => {
clearError();
validationError.value = '';
// Validate passwords match
if (form.password !== form.confirmPassword) {
validationError.value = 'Passwords do not match';
return;
}
// Validate password strength
if (form.password.length < 8) {
validationError.value = 'Password must be at least 8 characters';
return;
}
try {
const result = await signUp({
username: form.email,
password: form.password,
options: {
userAttributes: {
email: form.email,
},
},
});
if (result.nextStep.signUpStep === 'CONFIRM_SIGN_UP') {
// Navigate to confirmation page with email
navigateTo({
path: '/auth/confirm',
query: { email: form.email },
});
}
} catch (e) {
// Error is already captured in composable
console.error('Sign up failed:', e);
}
};
</script>
<template>
<div class="auth-container">
<div class="auth-card">
<h1>Create Account</h1>
<p class="subtitle">Sign up to get started</p>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
placeholder="you@example.com"
required
:disabled="isLoading"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
placeholder="At least 8 characters"
required
:disabled="isLoading"
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
v-model="form.confirmPassword"
type="password"
placeholder="Confirm your password"
required
:disabled="isLoading"
/>
</div>
<p v-if="validationError || error" class="error-message">
{{ validationError || error }}
</p>
<button type="submit" class="btn-primary" :disabled="isLoading">
{{ isLoading ? 'Creating Account...' : 'Create Account' }}
</button>
</form>
<p class="auth-link">
Already have an account?
<NuxtLink to="/auth/signin">Sign In</NuxtLink>
</p>
</div>
</div>
</template>
<style scoped>
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #f3f4f6;
}
.auth-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.auth-card h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.subtitle {
color: #6b7280;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #ff9900;
box-shadow: 0 0 0 3px rgba(255, 153, 0, 0.2);
}
.error-message {
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: #ff9900;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #e68a00;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-link {
text-align: center;
margin-top: 1.5rem;
color: #6b7280;
}
.auth-link a {
color: #ff9900;
font-weight: 500;
}
</style>
Email Confirmation Page
Create pages/auth/confirm.vue:
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const route = useRoute();
const { confirmSignUp, signIn, isLoading, error, clearError } = useAmplifyAuth();
const email = computed(() => route.query.email as string || '');
const code = ref('');
const handleConfirm = async () => {
clearError();
if (!email.value) {
return navigateTo('/auth/signup');
}
try {
const result = await confirmSignUp(email.value, code.value);
if (result.isSignUpComplete) {
// Sign in automatically after confirmation
// Note: You may want to redirect to sign-in page instead
navigateTo('/auth/signin?confirmed=true');
}
} catch (e) {
console.error('Confirmation failed:', e);
}
};
</script>
<template>
<div class="auth-container">
<div class="auth-card">
<h1>Verify Your Email</h1>
<p class="subtitle">
We sent a verification code to<br />
<strong>{{ email }}</strong>
</p>
<form @submit.prevent="handleConfirm">
<div class="form-group">
<label for="code">Verification Code</label>
<input
id="code"
v-model="code"
type="text"
placeholder="Enter 6-digit code"
required
:disabled="isLoading"
autocomplete="one-time-code"
/>
</div>
<p v-if="error" class="error-message">{{ error }}</p>
<button type="submit" class="btn-primary" :disabled="isLoading">
{{ isLoading ? 'Verifying...' : 'Verify Email' }}
</button>
</form>
<p class="auth-link">
Didn't receive a code?
<button class="link-button">Resend Code</button>
</p>
</div>
</div>
</template>
<style scoped>
/* Same styles as signup.vue plus: */
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #f3f4f6;
}
.auth-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.auth-card h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.subtitle {
color: #6b7280;
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
text-align: center;
letter-spacing: 0.5em;
font-size: 1.25rem;
}
.form-group input:focus {
outline: none;
border-color: #ff9900;
box-shadow: 0 0 0 3px rgba(255, 153, 0, 0.2);
}
.error-message {
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: #ff9900;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #e68a00;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-link {
text-align: center;
margin-top: 1.5rem;
color: #6b7280;
}
.link-button {
background: none;
border: none;
color: #ff9900;
font-weight: 500;
cursor: pointer;
font-size: 1rem;
}
.link-button:hover {
text-decoration: underline;
}
</style>
Sign In Page
Create pages/auth/signin.vue:
<script setup lang="ts">
definePageMeta({
layout: 'auth',
});
const route = useRoute();
const { signIn, isLoading, error, clearError } = useAmplifyAuth();
const form = reactive({
email: '',
password: '',
});
const showConfirmedMessage = computed(() => route.query.confirmed === 'true');
const handleSubmit = async () => {
clearError();
try {
const result = await signIn({
username: form.email,
password: form.password,
});
if (result.isSignedIn) {
// Redirect to home or intended destination
const redirectTo = route.query.redirect as string || '/';
navigateTo(redirectTo);
}
} catch (e) {
console.error('Sign in failed:', e);
}
};
</script>
<template>
<div class="auth-container">
<div class="auth-card">
<h1>Welcome Back</h1>
<p class="subtitle">Sign in to your account</p>
<div v-if="showConfirmedMessage" class="success-message">
Email verified! You can now sign in.
</div>
<form @submit.prevent="handleSubmit">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="form.email"
type="email"
placeholder="you@example.com"
required
:disabled="isLoading"
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="form.password"
type="password"
placeholder="Your password"
required
:disabled="isLoading"
/>
</div>
<p v-if="error" class="error-message">{{ error }}</p>
<button type="submit" class="btn-primary" :disabled="isLoading">
{{ isLoading ? 'Signing In...' : 'Sign In' }}
</button>
</form>
<p class="auth-link">
<NuxtLink to="/auth/forgot-password">Forgot your password?</NuxtLink>
</p>
<hr class="divider" />
<p class="auth-link">
Don't have an account?
<NuxtLink to="/auth/signup">Sign Up</NuxtLink>
</p>
</div>
</div>
</template>
<style scoped>
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: #f3f4f6;
}
.auth-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.auth-card h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
.subtitle {
color: #6b7280;
margin-bottom: 1.5rem;
}
.success-message {
background: #d1fae5;
color: #065f46;
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
}
.form-group input:focus {
outline: none;
border-color: #ff9900;
box-shadow: 0 0 0 3px rgba(255, 153, 0, 0.2);
}
.error-message {
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: #ff9900;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #e68a00;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.divider {
margin: 1.5rem 0;
border: none;
border-top: 1px solid #e5e7eb;
}
.auth-link {
text-align: center;
margin-top: 1rem;
color: #6b7280;
}
.auth-link a {
color: #ff9900;
font-weight: 500;
}
</style>
Create Auth Layout
Create a minimal layout for auth pages at layouts/auth.vue:
<template>
<div class="auth-layout">
<slot />
</div>
</template>
<style scoped>
.auth-layout {
min-height: 100vh;
}
</style>
Add Sign Out Button
Add a sign out button to your main layout or navigation. Here's a component you can use anywhere:
Create components/UserMenu.vue:
<script setup lang="ts">
const { user, isAuthenticated, signOut, isLoading } = useAmplifyAuth();
const handleSignOut = async () => {
await signOut();
navigateTo('/auth/signin');
};
</script>
<template>
<div class="user-menu">
<template v-if="isAuthenticated">
<span class="user-email">{{ user?.signInDetails?.loginId }}</span>
<button
class="btn-signout"
@click="handleSignOut"
:disabled="isLoading"
>
Sign Out
</button>
</template>
<template v-else>
<NuxtLink to="/auth/signin" class="btn-signin">Sign In</NuxtLink>
</template>
</div>
</template>
<style scoped>
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
}
.user-email {
font-size: 0.875rem;
color: #6b7280;
}
.btn-signout {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-signout:hover {
background: #f3f4f6;
}
.btn-signin {
padding: 0.5rem 1rem;
background: #ff9900;
color: white;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
}
.btn-signin:hover {
background: #e68a00;
}
</style>
Initialize Auth on App Load
Check if the user is authenticated when the app loads. Create a plugin at
plugins/auth.client.ts:
// plugins/auth.client.ts
export default defineNuxtPlugin(async () => {
const { checkAuth } = useAmplifyAuth();
// Check authentication status on app load
await checkAuth();
});
The .client.ts suffix ensures this plugin only runs in the
browser, not during server-side rendering. Auth checks require browser
storage access.
Test the Auth Flow
- Start your Nuxt dev server:
npm run dev - Start the Amplify sandbox:
npx ampx sandbox - Navigate to
/auth/signup - Create an account with a real email
- Check your email for the verification code
- Enter the code on the confirmation page
- Sign in with your credentials
If you're using the local mock (covered in Module 7), the verification code will appear as a browser alert instead of being emailed.
Summary
- Created sign up, confirmation, and sign in pages
- Built a UserMenu component for sign out
- Used the auth composable for all auth operations
- Created a client plugin to check auth on app load
- Handled loading states and error messages