name: amplify-monorepo description: Scaffold projects with Amplify Gen 2 + Nx monorepo architecture — two Next.js apps (public web + admin), shared design system, transactional email with Maizzle, next-intl i18n, and SaaS webhook integrations. disable-model-invocation: false
Amplify Monorepo Architecture Skill
When to use this architecture
Use it when the project requires:
- Two distinct frontends: a public-facing app (end users) and a separate administration app
- Three or more user roles with clearly different permissions (e.g. ADMIN, OPERATOR, USER) — if there is only on or two basic roles, splitting into two apps is not justified
- Serverless backend on AWS with business logic in Lambda
- Authentication with Cognito (including email OTP, user groups)
- Transactional email with rich HTML templates (confirmations, receipts, notifications)
- Integration with external SaaS services that require webhooks (payment gateways, SMS platforms, CRMs, etc.)
- Multi-language support in the frontend (next-intl)
- Shared design system between both apps
Do not use it when:
- There is only one frontend — a single Next.js app is enough
- All users share the same role or permissions are trivial — the overhead of Cognito groups and group-based authorization adds no value
- The project is a content site (CMS, blog, landing page) with no transactional operations — a Next.js app with a headless CMS is more appropriate
- The backend has complex logic that does not fit serverless — consider a dedicated server (Nest.js, Go)
- There is no transactional email, external integrations, or sensitive data — the CDK complexity is not justified
- The team has no experience with AWS/Amplify — the learning curve is steep
Security criteria for adopting this architecture
This architecture enforces security controls that only make sense for transactional projects or projects with sensitive data. Evaluate each criterion before adopting it:
| Criterion | Justifies this architecture | Simpler alternative |
|---|---|---|
| Handles PII (documents, phone numbers, identity) | Yes — Cognito + group-based authorization | Does not apply if data is public |
| Payment or subscription operations | Yes — webhook with authorizer, transaction audit log | Does not apply for informational sites |
| Roles with radically different permissions (admin vs user) | Yes — separate admin app, Cognito groups, per-function IAM policies | Does not apply if all users see the same content |
| Third-party webhooks that cannot authenticate with Cognito | Yes — API Gateway + Lambda authorizer with HMAC token | Does not apply if there are no external integrations |
| Audit trail for critical actions | Yes — AuditLog model with actor + resource + action |
Does not apply for low-risk operations |
| Attack surface separation | Yes — admin on a separate domain, not publicly exposed | Does not apply if the admin panel is internal or low-risk |
Warning: If none of the left-column criteria apply to the project, this architecture adds complexity with no real security benefit.
Tech stack
| Layer | Technology |
|---|---|
| Monorepo | Nx 22+ |
| Frontend | Next.js 15+ (App Router), TypeScript strict, TailwindCSS |
| Auth | AWS Cognito (via Amplify Gen 2) |
| Backend | AWS Amplify Gen 2 (@aws-amplify/backend) |
| Database | DynamoDB via AppSync GraphQL |
| Lambda | Node.js 20+, TypeScript |
| Amazon SES + Maizzle | |
| SaaS integrations | Webhooks via API Gateway + Lambda authorizer (payment gateways, SMS, CRM, etc.) |
| i18n | next-intl |
| UI | CVA + TailwindCSS (custom design system) |
| Tests | Jest + Testing Library, Playwright |
| CI/CD | Amplify Hosting + amplify.yml |
Monorepo structure
<project-name>/
├── apps/
│ ├── admin/ # Administration app (Next.js)
│ │ ├── amplify/ # AWS Amplify Gen 2 backend ← backend lives here
│ │ │ ├── backend.ts # Single composition root
│ │ │ ├── auth/
│ │ │ │ └── resource.ts # Cognito: login, groups, attributes, triggers
│ │ │ ├── data/
│ │ │ │ └── resource.ts # GraphQL schema: models, queries, mutations
│ │ │ ├── functions/
│ │ │ │ ├── cognito-triggers/ # pre-signup, post-confirmation, custom-message
│ │ │ │ ├── notifications/ # One function per email type
│ │ │ │ ├── [integration]/ # Webhook handler + authorizer per SaaS integration
│ │ │ │ ├── services/ # Multi-step business logic
│ │ │ │ ├── users/ # Cognito UserPool operations (list, set, toggle)
│ │ │ │ └── shared/ # Shared utilities across functions
│ │ │ │ ├── common/ # Types, constants, audit, identity, security
│ │ │ │ ├── email/ # Template renderer, SES send with retry
│ │ │ │ └── [integration]/ # SaaS SDK client, types, utils (e.g. payment/, sms/)
│ │ │ └── cdk/ # CDK constructs for resources outside Amplify scope
│ │ │ ├── ses/ # SES domain identity + IAM policy
│ │ │ └── functions/ # Lambda Layers (email templates, etc.)
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ ├── login/
│ │ │ │ └── dashboard/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── page.tsx
│ │ │ │ └── [resource]/ # One folder per entity (users, plans, sessions…)
│ │ │ ├── actions/ # Next.js Server Actions: mutations and queries
│ │ │ ├── components/
│ │ │ │ ├── auth/
│ │ │ │ ├── layout/
│ │ │ │ └── [resource]/ # Domain components: table, modals, types
│ │ │ ├── i18n/
│ │ │ │ ├── routing.ts # Locale definitions
│ │ │ │ ├── request.ts
│ │ │ │ └── load-messages.ts
│ │ │ └── lib/
│ │ │ └── formatters.ts # Date/currency formatters with timezone
│ │ └── messages/ # Translations by locale: es/, en/, pt/
│ │
│ ├── web/ # Public app (Next.js)
│ │ ├── src/
│ │ │ ├── app/
│ │ │ │ └── (public)/ # Public route group
│ │ │ ├── actions/ # Server Actions: backend operations
│ │ │ ├── components/
│ │ │ │ ├── amplify/ # ConfigureAmplifyClientSide.tsx
│ │ │ │ ├── auth/
│ │ │ │ ├── layout/
│ │ │ │ ├── molecules/
│ │ │ │ └── organisms/
│ │ │ ├── i18n/
│ │ │ └── lib/
│ │ │ └── client.ts # getGuestClient / getAuthenticatedClient
│ │ └── messages/
│ │
│ └── web-e2e/ # E2E tests with Playwright
│
├── packages/
│ ├── ui/ # Shared design system (@<project>/ui)
│ │ └── src/
│ │ ├── atoms/ # Button, Input, Badge, Heading, Text…
│ │ ├── molecules/ # Form, Card, Modal, Dropdown, LanguageSelector…
│ │ ├── organisms/ # DataTable, Sidebar, Banner…
│ │ ├── icons/
│ │ └── styles/
│ │ └── globals.css
│ │
│ ├── core/ # Shared utilities (@<project>/core)
│ │ └── src/
│ │ └── auth/
│ │ ├── client.ts # createServerClient factory
│ │ ├── server.ts # Server-side auth utils
│ │ ├── middleware.ts # Next.js middleware
│ │ └── services/
│ │ └── user.ts # Fetch user attributes from Cognito
│ │
│ └── email-templates/ # Maizzle templates (@<project>/email-templates)
│ ├── emails/ # HTML templates (one per email type)
│ ├── components/ # Reusable email components
│ ├── layouts/
│ │ └── base.html
│ ├── partials/
│ │ ├── headers/
│ │ └── footers/
│ ├── static/ # Images, logos
│ ├── config.js # Maizzle dev config
│ └── config.production.js # Maizzle production config
│
├── config/
│ └── amplify_outputs.json # Generated by Amplify CLI — consumed by both apps
│
├── amplify.yml # Amplify Hosting CI/CD
├── nx.json
├── tsconfig.base.json
└── package.json
Package dependency rules
apps/* → packages/* ✓ apps import from packages
packages/* → packages/* ✗ packages are independent of each other
packages/* → apps/* ✗ never
amplify/functions/* → amplify/functions/shared/ ✓
amplify/functions/shared/ → node_modules only ✓
Step 1 — Scaffolding with Nx
1.1 Create the workspace
npx create-nx-workspace@latest <project-name> \
--preset=empty \
--packageManager=npm \
--workspaceType=integrated
cd <project-name>
1.2 Install required Nx plugins
npm install -D @nx/next @nx/react @nx/eslint @nx/jest @nx/playwright @nx/js
1.3 Create the admin app (backend owner)
npx nx generate @nx/next:application admin \
--directory=apps/admin \
--style=tailwind \
--linter=eslint \
--e2eTestRunner=none \
--unitTestRunner=jest \
--appDir=true \
--srcDir=true
1.4 Create the public web app
npx nx generate @nx/next:application web \
--directory=apps/web \
--style=tailwind \
--linter=eslint \
--e2eTestRunner=none \
--unitTestRunner=jest \
--appDir=true \
--srcDir=true
1.5 Create the E2E app
npx nx generate @nx/playwright:configuration web-e2e \
--project=web \
--directory=apps/web-e2e
1.6 Create the UI package (design system)
npx nx generate @nx/react:library ui \
--directory=packages/ui \
--style=tailwind \
--linter=eslint \
--unitTestRunner=jest \
--bundler=none \
--component=false
1.7 Create the Core package (shared auth)
npx nx generate @nx/js:library core \
--directory=packages/core \
--linter=eslint \
--unitTestRunner=jest \
--bundler=none
1.8 Create the email-templates package (manual)
packages/email-templates/ has no native Nx generator. Create it manually:
mkdir -p packages/email-templates/{emails,components,layouts,partials/{headers,footers},static}
Install Maizzle:
npm install -D @maizzle/framework tailwindcss-preset-email juice
1.9 Configure path aliases in tsconfig.base.json
{
"compilerOptions": {
"paths": {
"$amplify/*": ["apps/admin/.amplify/generated/*"],
"@amplify/*": ["apps/admin/amplify/*"],
"@config/*": ["config/*"],
"@<project>/core/*": ["packages/core/src/*"],
"@<project>/ui/*": ["packages/ui/src/*"]
}
}
}
Step 2 — AWS Amplify Gen 2 backend
2.1 Install Amplify
npm install aws-amplify @aws-amplify/backend @aws-amplify/backend-cli
npm install -D aws-cdk-lib constructs
2.2 Initialize the backend in apps/admin/
cd apps/admin
npx ampx configure project
This generates apps/admin/amplify/ with the base structure.
2.3 Auth — apps/admin/amplify/auth/resource.ts
Rules:
- Cognito is the single source of truth for users. Do not create a
Usermodel in DynamoDB. - Lambda function permissions on the Cognito UserPool are declared in
access(), not in the handlers. - Cognito triggers are registered in
triggers, not as standalone functions.
import { defineAuth } from '@aws-amplify/backend';
export const auth = defineAuth({
loginWith: {
email: {
otpLogin: true, // Email OTP instead of password
},
},
senders: {
email: {
fromEmail: 'auth@<domain>',
fromName: process.env.APP_NAME || '<AppName>',
},
},
triggers: {
customMessage: customMessageTrigger, // Customize Cognito emails
preSignUp: preSignUpTrigger, // Validate document uniqueness
postConfirmation: postConfirmationTrigger, // Assign initial group
},
groups: ['ADMIN', 'OPERATOR', 'USER'], // Adjust to domain roles
userAttributes: {
givenName: { mutable: true },
familyName: { mutable: true },
email: { required: true, mutable: true },
phoneNumber: { mutable: true },
// Domain-specific custom attributes
'custom:document_type': { dataType: 'String', mutable: false },
'custom:document_number': { dataType: 'String', mutable: false },
'custom:subscription_level': { dataType: 'String', mutable: true },
},
accountRecovery: 'EMAIL_ONLY',
access: (allow) => [
// Declare each Lambda's permissions on the UserPool
allow.resource(postConfirmationTrigger).to(['addUserToGroup']),
allow.resource(preSignUpTrigger).to(['listUsers']),
allow.resource(listUsersFunction).to(['listUsersInGroup']),
allow.resource(setUserFunction).to(['getUser', 'createUser', 'addUserToGroup', 'updateUserAttributes']),
allow.resource(toggleUserFunction).to(['disableUser', 'enableUser']),
],
});
2.4 Data schema — apps/admin/amplify/data/resource.ts
Rules:
- Authorization is declared in the schema, never in Lambda handlers.
- Cognito operations are exposed as custom
queryormutationwitha.handler.function(). - Email notifications are exposed as async mutations (
.async()). defaultAuthorizationMode: 'apiKey'enables public read without login (for catalogs, pricing).- Resources visible only to authenticated users use
allow.authenticated(). - Single-owner resources use
allow.owner()orallow.ownerDefinedIn('fieldName').
import { a, ClientSchema, defineData } from '@aws-amplify/backend';
const schema = a
.schema({
// ── Domain enums ──────────────────────────────────────────────
MyEnum: a.enum(['VALUE_A', 'VALUE_B']),
// ── Custom types for Lambda-backed mutations/queries ──────────
MyCustomInput: a.customType({
field: a.string().required(),
}),
// ── Data models ───────────────────────────────────────────────
MyModel: a
.model({
name: a.string().required(),
ownerSub: a.string().required(),
})
.authorization((allow) => [allow.ownerDefinedIn('ownerSub').to(['read']), allow.groups(['ADMIN']).to(['read', 'create', 'update', 'delete'])]),
// ── Async mutations for email notifications ───────────────────
sendNotificationEmail: a
.mutation()
.arguments({ to: a.string().required(), data: a.ref('MyCustomInput').required() })
.handler(a.handler.function(myNotificationFunction).async())
.authorization((allow) => [allow.authenticated()]),
// ── Lambda-backed queries/mutations for Cognito operations ────
listUsers: a
.query()
.returns(a.ref('UserItem').array())
.handler(a.handler.function(listUsersFunction))
.authorization((allow) => [allow.groups(['ADMIN'])]),
})
// Allow Lambda functions to access the schema without user authentication
.authorization((allow) => [allow.resource(webhookFunction), allow.resource(setUserFunction)]);
export type Schema = ClientSchema<typeof schema>;
export const data = defineData({
schema,
authorizationModes: {
defaultAuthorizationMode: 'apiKey', // Public read (catalogs)
apiKeyAuthorizationMode: { expiresInDays: 30 },
},
});
2.5 Lambda functions — domain-based structure
Each domain has its own directory with resource.ts + handlers:
functions/
├── [domain]/
│ ├── resource.ts # defineFunction(): name, entry, env, timeout, memoryMB
│ └── handler.ts # Lambda handler (or separate files per operation)
├── cognito-triggers/ # pre-signup, post-confirmation, custom-message
├── notifications/ # One function per email type
├── [integration]/ # One folder per SaaS webhook integration (e.g. payments/, sms/, crm/)
│ ├── resource.ts # Defines the main handler + Lambda authorizer
│ ├── handler.ts # Processes the webhook event
│ └── authorizer.ts # Verifies the provider signature/token (HMAC, Bearer, etc.)
├── services/ # Multi-step logic (e.g. orchestrating multiple operations)
├── users/ # Cognito UserPool operations
└── shared/
├── common/ # Types, constants, audit, identity, security
├── email/ # Maizzle template renderer, SES send with retry
└── [integration]/ # SaaS SDK client, types and utilities (e.g. payment/, sms/)
resource.ts example for modular notifications:
import { defineFunction } from '@aws-amplify/backend';
const sharedEmailEnv = {
NOREPLY_EMAIL: process.env.NOREPLY_EMAIL || 'noreply@<domain>',
SUPPORT_EMAIL: process.env.SUPPORT_EMAIL || 'support@<domain>',
APP_NAME: process.env.APP_NAME || '<AppName>',
ADMIN_EMAIL: process.env.ADMIN_EMAIL || 'admin@<domain>',
};
export const sendWelcomeFunction = defineFunction({
name: 'sendWelcome',
entry: './sendWelcome.ts',
environment: sharedEmailEnv,
timeoutSeconds: 30,
memoryMB: 512, // Higher because Maizzle renders at runtime
});
export const sendReceiptFunction = defineFunction({
name: 'sendReceipt',
entry: './sendReceipt.ts',
environment: sharedEmailEnv,
timeoutSeconds: 30,
memoryMB: 512,
});
Rules for functions:
- One
resource.tsper domain directory — defines all functions for that domain. - Logic shared between functions goes in
functions/shared/, never duplicated. - Environment variables: injected in
resource.ts, never hardcoded in the handler. - Secrets (API keys, passwords): use
ampx secret set <NAME>and reference withsecret('NAME').
2.6 backend.ts — composition root
import { defineBackend } from '@aws-amplify/backend';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { auth } from './auth/resource';
import { data } from './data/resource';
import { sendWelcomeFunction, sendReceiptFunction } from './functions/notifications/resource';
import { LambdaLayerEmailTemplates } from './cdk/functions/email-templates-layer';
import { SESEmailIdentity } from './cdk/ses/email-identity';
export const backend = defineBackend({
auth,
data,
sendWelcomeFunction,
sendReceiptFunction,
// ...all other functions
});
// CDK customization — only for resources not covered by Amplify (see Step 3)
const backendStack = backend.createStack('BackendStack');
const emailTemplatesLayer = new LambdaLayerEmailTemplates(backendStack, 'EmailTemplatesLayer');
const sesConfig = new SESEmailIdentity(backendStack, 'SESConfig', { domain: '<domain>' });
const notificationFunctions = [backend.sendWelcomeFunction, backend.sendReceiptFunction];
notificationFunctions.forEach((func) => {
const lambdaFn = func.resources.lambda as lambda.Function;
lambdaFn.addLayers(emailTemplatesLayer);
lambdaFn.role?.addManagedPolicy(sesConfig.sendPolicy);
});
Rules for backend.ts:
- Single composition root — functions do not import from each other directly.
- No business logic here — only instantiate CDK resources and wire them together.
Step 3 — CDK: Amplify-first decision process
Before writing any CDK construct, check the official Amplify Gen 2 documentation:
If the infrastructure resource is listed there, use the Amplify abstraction. Only fall back to raw CDK when Amplify does not cover the resource.
Decision flowchart
Need an AWS resource?
│
▼
Is it listed at docs.amplify.aws/nextjs/build-a-backend/?
│
YES ─┤─ NO
│ └─► Use CDK via backend.createStack()
▼
Use the Amplify abstraction:
- Auth → defineAuth()
- Data → defineData() / a.schema()
- Functions → defineFunction()
- Hosting → amplify.yml
- Storage → defineStorage()
What Amplify Gen 2 covers (no CDK needed)
- Cognito UserPool, UserPoolClient, Identity Pool
- AppSync API + DynamoDB (via
defineData) - Lambda functions (via
defineFunction) - S3 storage (via
defineStorage) - Basic IAM policies between Amplify resources (via
access()in auth and.authorization()in data) - Amplify Hosting (via
amplify.yml)
What requires CDK directly
| Resource | Why Amplify does not cover it |
|---|---|
| SES domain identity | Amplify does not manage SES |
| Lambda Layers | No defineLayer exists in Amplify Gen 2 |
| API Gateway (external webhooks) | Amplify does not expose public HTTP endpoints without Cognito auth |
| Lambda authorizer for webhooks | Depends on custom API Gateway |
| KMS key for custom SMS sender | Non-standard Cognito configuration |
| Any AWS service not listed above | Use backend.createStack() + CDK |
CDK construct — SES
// apps/admin/amplify/cdk/ses/email-identity.ts
import * as iam from 'aws-cdk-lib/aws-iam';
import * as ses from 'aws-cdk-lib/aws-ses';
import { Construct } from 'constructs';
export class SESEmailIdentity extends Construct {
public readonly sendPolicy: iam.ManagedPolicy;
constructor(scope: Construct, id: string, props: { domain: string }) {
super(scope, id);
ses.EmailIdentity.fromEmailIdentityName(this, 'DomainIdentity', props.domain);
this.sendPolicy = new iam.ManagedPolicy(this, 'SESPolicy', {
statements: [
new iam.PolicyStatement({
actions: ['ses:SendEmail', 'ses:SendRawEmail'],
resources: ['*'],
conditions: { StringLike: { 'ses:FromAddress': `*@${props.domain}` } },
}),
],
});
}
}
Required manual setup:
- Verify the domain in AWS SES Console (sandbox → production).
- Configure DNS records: DKIM, SPF, DMARC.
- Request sandbox exit if sending to unverified emails.
CDK construct — Lambda Layer for email templates
// apps/admin/amplify/cdk/functions/email-templates-layer.ts
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Stack } from 'aws-cdk-lib';
export class LambdaLayerEmailTemplates extends lambda.LayerVersion {
constructor(scope: Stack, id: string) {
super(scope, id, {
code: lambda.Code.fromAsset('../../packages/email-templates', {
bundling: {
local: {
tryBundle(outputDir) {
// Copy emails/, components/, layouts/, partials/, static/, config.js
// to outputDir/opt/email-templates/
// Return true on success, false to fall back to Docker
},
},
image: lambda.Runtime.NODEJS_20_X.bundlingImage,
command: ['bash', '-c', 'cp -r emails components layouts partials static config.js config.production.js /asset-output/opt/email-templates/'],
},
}),
compatibleRuntimes: [lambda.Runtime.NODEJS_20_X, lambda.Runtime.NODEJS_22_X],
description: 'Maizzle email templates',
});
}
}
CDK — SaaS webhook integrations (API Gateway)
When an external SaaS (payment gateway, SMS provider, CRM) needs to push events to the app via HTTP and cannot authenticate with Cognito. The pattern is always the same: API Gateway + Lambda handler + Lambda authorizer with provider signature verification.
// In backend.ts, inside createStack():
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
// One RestApi can have multiple resources, one per integration
const webhooksApi = new apigateway.RestApi(backendStack, 'WebhooksApi', {
restApiName: 'webhooks-api',
});
const webhooksRoot = webhooksApi.root.addResource('webhooks');
// Example: payment gateway integration
const paymentsResource = webhooksRoot.addResource('payments');
const paymentsAuthorizer = new apigateway.RequestAuthorizer(backendStack, 'PaymentsAuthorizer', {
handler: backend.paymentsAuthorizerFunction.resources.lambda,
// The header carrying the token/signature depends on the provider
identitySources: [apigateway.IdentitySource.header('x-webhook-token')],
});
paymentsResource.addMethod('POST', new apigateway.LambdaIntegration(backend.paymentsFunction.resources.lambda), {
authorizationType: apigateway.AuthorizationType.CUSTOM,
authorizer: paymentsAuthorizer,
});
// Add more integrations following the same pattern
// const smsResource = webhooksRoot.addResource('sms');
new cdk.CfnOutput(backendStack, 'PaymentsWebhookUrl', {
value: `${webhooksApi.url}webhooks/payments`,
});
Lambda authorizer — generic pattern:
The authorizer verifies that the event comes from the legitimate provider. The mechanism depends on the SaaS:
- HMAC-SHA256 (Stripe, GitHub, most payment gateways): compare header signature with payload hash
- Secret Bearer token (Twilio, SendGrid): compare header with stored secret
- IP allowlist: validate origin IP from API Gateway headers
// functions/[integration]/authorizer.ts
import { verifyHmacSignature } from '../shared/common/security';
export const handler = async (event: APIGatewayAuthorizerEvent) => {
const signature = event.headers['x-signature'] ?? '';
const secret = process.env.WEBHOOK_SECRET!;
const isValid = verifyHmacSignature(event.body ?? '', signature, secret);
return {
principalId: 'webhook',
policyDocument: {
Statement: [
{
Action: 'execute-api:Invoke',
Effect: isValid ? 'Allow' : 'Deny',
Resource: event.methodArn,
},
],
},
};
};
Step 4 — Consuming the backend from the apps
See references/amplify-clients.md for the createServerClient factory pattern in packages/core and the getGuestClient / getAuthenticatedClient factories per app.
For each domain that needs a Server Action, invoke /amplify-action <domain> cognito (or groups for admin operations).
Rules:
apps/*/src/actions/is the only place where Amplify client is instantiated. No network calls in components.- Client Components call Server Actions via form
actionprops orstartTransition. Never directly. - Actions always return
Result<T, AppError>. Seereferences/amplify-clients.mdfor the error types.
Step 5 — Transactional email with Maizzle
Invoke /maizzle-email <ses-domain> to scaffold packages/email-templates with Maizzle, the Lambda renderer helper, the CDK SES domain identity construct, and the Lambda Layer for template bundling. Wire the layer and SES policy in backend.ts as described in the skill output.
Note: SES domain verification and DNS records (DKIM, SPF, DMARC) require manual steps in AWS Console.
Step 6 — i18n in Next.js apps
Invoke /next-intl-setup apps/web/src/ and then /next-intl-setup apps/admin/src/ to configure next-intl in each app separately.
Rules:
- Each app has its own
messages/directory — translations are not shared between apps. - Both apps use the same locale structure:
es/(default),en/,pt/withcommon.jsonper locale.
Step 7 — Design system (packages/ui)
Invoke /tailwind-theme packages/ui/src/styles/globals.css to configure the shared @theme token block. Both apps import this file — do not duplicate it.
For each UI component in packages/ui, invoke /ui-component <Name> <atom|molecule|organism> [variants].
Rules:
- No business logic in
packages/ui— UI only: styles, variants, layout. - All components typed with explicit
interface. Noany. - All components exported from
src/index.ts. react-hook-form,@hookform/resolvers, andzodare needed for molecules with forms.
Step 8 — CI/CD with amplify.yml
The admin app owns the backend. The web app only consumes it.
version: 1
applications:
# Admin: deploys backend + its frontend
- appRoot: apps/admin
backend:
phases:
preBuild:
commands:
- cd ../..
- npm ci --include=optional --cache .npm --prefer-offline
build:
commands:
- cd $AMPLIFY_MONOREPO_APP_ROOT
- npx ampx pipeline-deploy --outputs-out-dir ../../config --outputs-format json --branch $AWS_BRANCH --app-id $AWS_APP_ID
frontend:
phases:
build:
commands:
- cd ../..
- npx nx build admin --configuration=production
artifacts:
baseDirectory: .next
files: ['**/*']
cache:
paths: [node_modules/**, .nx/cache/**, .next/cache/**]
# Web: frontend only, consumes the admin backend
- appRoot: apps/web
frontend:
phases:
preBuild:
commands:
- cd ../..
- npm ci --include=optional --cache .npm --prefer-offline
build:
commands:
- cd $AMPLIFY_MONOREPO_APP_ROOT
# Download outputs generated by the admin app
- npx ampx generate outputs --out-dir ../../config --format json --branch $AWS_BRANCH --app-id $BACKEND_APP_ID
- cd ../..
- npx nx build web --configuration=production
artifacts:
baseDirectory: .next
files: ['**/*']
cache:
paths: [node_modules/**, .nx/cache/**, .next/cache/**]
Environment variables — three levels
Level 1 — Local development (.env.local, never committed):
# apps/admin/.env.local
APP_NAME="My App (Dev)"
NOREPLY_EMAIL="dev@example.com"
SUPPORT_EMAIL="support@example.com"
ADMIN_EMAIL="admin@example.com"
WEBHOOK_SECRET="dev-secret-local"
# apps/web/.env.local
NEXT_PUBLIC_APP_URL="http://localhost:4200"
.env.local is git-ignored. Each developer sets it up manually from a committed .env.example file that contains keys but no real values.
Level 2 — Sensitive secrets (ampx secret, encrypted in AWS):
For third-party API keys that must not appear in code or plain environment variables:
npx ampx secret set WEBHOOK_SECRET
npx ampx secret set PAYMENT_API_KEY
Reference them in resource.ts with secret('NAME'), not process.env.
Level 3 — Amplify Console environment variables (non-sensitive, per environment):
Build and runtime variables are configured in Amplify Console > App settings > Environment variables. This configuration is not in the code — it must be documented in the project README or operations documentation:
# Admin app — required variables in Amplify Console
AWS_APP_ID → (set automatically)
APP_NAME → "My App"
NOREPLY_EMAIL → "noreply@domain.com"
SUPPORT_EMAIL → "support@domain.com"
ADMIN_EMAIL → "admin@domain.com"
# Web app — required variables in Amplify Console
BACKEND_APP_ID → App ID of the admin app (to download amplify_outputs.json)
NEXT_PUBLIC_APP_URL → Public URL of the web app
New project checklist
Architecture decision:
- The project has 3+ user roles with distinct permissions
- There are transactional operations, PII data, or SaaS integrations with webhooks
- Attack surface separation is required (admin on a separate domain)
Scaffolding:
- Nx workspace created
-
adminandwebapps generated with Nx -
ui,core,email-templatespackages created - Path aliases configured in
tsconfig.base.json -
.env.examplecommitted (no real values) and.env.localin.gitignore
Amplify backend:
-
apps/admin/amplify/initialized withampx configure project - Each infrastructure need checked against https://docs.amplify.aws/nextjs/build-a-backend/ before writing CDK
-
auth/resource.tswith groups, triggers, andaccess()permissions -
data/resource.tswith schema, models, group/owner authorization, mutations, queries - Lambda functions organized by domain in
functions/ -
[integration]/folder with handler + authorizer for each external SaaS webhook -
shared/with constants, types, email renderer, SaaS SDK client per integration - Secrets configured with
ampx secret set(not as plain environment variables)
CDK:
-
SESEmailIdentityconstruct created -
LambdaLayerEmailTemplatesconstruct created -
backend.tswires layers and SES policies to notification functions - API Gateway with Lambda authorizer for each webhook integration (if applicable)
Frontend:
-
lib/client.tsin each app withgetGuestClient/getAuthenticatedClient -
actions/with Server Actions typed withResult<T, E> -
packages/uiwith atoms, molecules, and organisms -
packages/ui/src/styles/globals.csswith@themedesign tokens — no raw hex values in components -
packages/corewithcreateServerClientandmiddleware.ts - i18n configured with next-intl in both apps (per-locale directories:
es/,en/,pt/)
Email:
-
packages/email-templates/configured with Maizzle - SES: domain verified, DNS configured (DKIM, SPF, DMARC), sandbox exit requested for production
CI/CD and operations:
-
amplify.ymlwith admin as backend owner and web as consumer - Amplify Console environment variables documented in the project README
-
BACKEND_APP_IDconfigured in the web app in Amplify Console